ソースを参照

loop: simplify to autoresearch pattern - minimal hook + LLM-driven loop

Replace event-driven LoopEngine with a thin /loop slash command hook.
No parsing, no regex, no flags - the LLM extracts goal,
successCriteria, and maxAttempts from natural language.

Removed:
- src/loop/loop-engine.ts (event-driven state machine)
- src/tools/loop-command.ts + test (SDK-dependent tool)
- loopDispatch/createLoopCommand/DispatchCallback from index.ts
- All semantic regex parsing (until, with, via, --type, interview-file)

Added:
- src/hooks/loop-command/ - minimal hook (82 lines)
- src/loop/loop-session.test.ts
- src/skills/loop-engineering/SKILL.md

File-based history via .opencode/loop-history/ per attempt.
The disk is memory. No state machine, no callbacks, no event plumbing.

Tests: 1221 pass. Build: clean. Typecheck: clean.

Closes: #612
Michael Henke 2 ヶ月 前
コミット
4c7e55e7a3

+ 2 - 0
.gitignore

@@ -37,6 +37,8 @@ coverage/
 tmp/
 temp/
 local
+.loop-history-*.md
+.opencode/loop-history/
 
 .sisyphus/
 .hive/

+ 1 - 0
src/hooks/index.ts

@@ -11,6 +11,7 @@ export {
 } from './foreground-fallback';
 export { processImageAttachments } from './image-hook';
 export { createJsonErrorRecoveryHook } from './json-error-recovery/hook';
+export { createLoopCommandHook } from './loop-command';
 export { createPhaseReminderHook } from './phase-reminder';
 export { createPostFileToolNudgeHook } from './post-file-tool-nudge';
 export { createReflectCommandHook } from './reflect';

+ 77 - 0
src/hooks/loop-command/index.test.ts

@@ -0,0 +1,77 @@
+import { describe, expect, test } from 'bun:test';
+import { createLoopCommandHook } from './index';
+
+describe('loop command hook', () => {
+  test('registers /loop command when absent', () => {
+    const hook = createLoopCommandHook();
+    const config: Record<string, unknown> = {};
+    hook.registerCommand(config);
+
+    const command = (config.command as Record<string, unknown>).loop as {
+      template: string;
+      description: string;
+    };
+
+    expect(command).toBeDefined();
+    expect(command.template).toContain('loop');
+    expect(command.description).toBeDefined();
+  });
+
+  test('does not overwrite existing /loop command', () => {
+    const hook = createLoopCommandHook();
+    const existing = { template: 'custom', description: 'custom loop' };
+    const config: Record<string, unknown> = { command: { loop: existing } };
+    hook.registerCommand(config);
+    expect((config.command as Record<string, unknown>).loop).toBe(existing);
+  });
+
+  test('shows help when no arguments provided', async () => {
+    const hook = createLoopCommandHook();
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'loop', sessionID: 's1', arguments: '  ' },
+      output,
+    );
+
+    expect(output.parts.length).toBe(1);
+    expect(output.parts[0].text).toContain('Usage');
+  });
+
+  test('generates activation prompt with user text', async () => {
+    const hook = createLoopCommandHook();
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+    await hook.handleCommandExecuteBefore(
+      {
+        command: 'loop',
+        sessionID: 's1',
+        arguments:
+          'fix typescript errors until typecheck passes, max 3 tries',
+      },
+      output,
+    );
+
+    const text = output.parts[0].text;
+    expect(output.parts.length).toBe(1);
+    expect(text).toContain('The user ran `/loop`');
+    expect(text).toContain('fix typescript errors until typecheck passes, max 3 tries');
+    expect(text).toContain('goal, successCriteria, maxAttempts');
+    expect(text).toContain('missing or unclear');
+    expect(text).toContain('.opencode/loop-history/');
+    expect(text).toContain('Dispatch @fixer');
+  });
+
+  test('ignores other commands', async () => {
+    const hook = createLoopCommandHook();
+    const output = { parts: [{ type: 'text' as const, text: 'original' }] };
+
+    await hook.handleCommandExecuteBefore(
+      { command: 'deepwork', sessionID: 's1', arguments: 'x' },
+      output,
+    );
+
+    expect(output.parts.length).toBe(1);
+    expect(output.parts[0].text).toBe('original');
+  });
+});

+ 80 - 0
src/hooks/loop-command/index.ts

@@ -0,0 +1,80 @@
+import { createInternalAgentTextPart } from '../../utils';
+
+const COMMAND_NAME = 'loop';
+
+function historyDir(): string {
+  const shortID = Math.random().toString(36).slice(2, 8);
+  const timestamp = Date.now().toString(36);
+  return `.opencode/loop-history/loop-${timestamp}-${shortID}`;
+}
+
+function activationPrompt(text: string): string {
+  const dir = historyDir();
+
+  return [
+    'The user ran `/loop`. From the text below, extract: goal, successCriteria, maxAttempts.',
+    '',
+    'If ANY are missing or unclear — push back and ask the user to clarify.',
+    'Do not assume or guess. All three must be explicit.',
+    '',
+    'Once all three are clear, run the loop:',
+    '',
+    text,
+    '',
+    'For each attempt:',
+    `1. Read \`${dir}/\` for prior results`,
+    '2. Dispatch @fixer with the goal',
+    '3. Verify per the successCriteria',
+    `4. Write result to \`${dir}/attempt-{N}.md\` (PASS/FAIL + reason)`,
+    '5. PASS -> stop. FAIL under maxAttempts -> retry. FAIL at max -> escalate.',
+  ].join('\n');
+}
+
+function helpPrompt(): string {
+  return [
+    'Usage: `/loop <description>`',
+    '',
+    'Describe what to accomplish, what success looks like, and how many tries.',
+    '',
+    'Examples:',
+    '  `/loop fix typescript errors until typecheck passes, max 3 tries`',
+    '  `/loop improve api performance until response under 500ms, try 5 times`',
+    '  `/loop refactor auth module, tests must pass, 4 attempts max`',
+  ].join('\n');
+}
+
+export function createLoopCommandHook(): {
+  registerCommand: (config: Record<string, unknown>) => void;
+  handleCommandExecuteBefore: (
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ) => Promise<void>;
+} {
+  return {
+    registerCommand: (opencodeConfig) => {
+      const cfg = opencodeConfig.command as
+        | Record<string, unknown>
+        | undefined;
+      if (cfg?.[COMMAND_NAME]) return;
+      if (!opencodeConfig.command) opencodeConfig.command = {};
+      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
+        template: 'Run an automated execute-verify loop',
+        description:
+          'Dispatch fixer, verify, iterate with file-based history on disk.',
+      };
+    },
+
+    handleCommandExecuteBefore: async (input, output) => {
+      if (input.command !== COMMAND_NAME) return;
+
+      output.parts.length = 0;
+      const args = input.arguments.trim();
+      if (!args) {
+        output.parts.push(createInternalAgentTextPart(helpPrompt()));
+        return;
+      }
+
+      output.parts.push({ type: 'text', text: activationPrompt(args) });
+    },
+  };
+}

+ 13 - 0
src/index.ts

@@ -30,6 +30,7 @@ import {
   createReflectCommandHook,
   createTaskSessionManagerHook,
   ForegroundFallbackManager,
+  createLoopCommandHook,
 } from './hooks';
 import { processImageAttachments } from './hooks/image-hook';
 import type { MessageWithParts } from './hooks/types';
@@ -148,6 +149,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let foregroundFallback: ForegroundFallbackManager;
   let deepworkCommandHook: ReturnType<typeof createDeepworkCommandHook>;
   let reflectCommandHook: ReturnType<typeof createReflectCommandHook>;
+  let loopCommandHook: ReturnType<typeof createLoopCommandHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
   let backgroundJobBoard: BackgroundJobBoard;
   let interviewManager: ReturnType<typeof createInterviewManager>;
@@ -305,6 +307,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     deepworkCommandHook = createDeepworkCommandHook();
     reflectCommandHook = createReflectCommandHook();
+    loopCommandHook = createLoopCommandHook();
     taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
       maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
       readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
@@ -740,6 +743,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       interviewManager.registerCommand(opencodeConfig);
       deepworkCommandHook.registerCommand(opencodeConfig);
       reflectCommandHook.registerCommand(opencodeConfig);
+      loopCommandHook.registerCommand(opencodeConfig);
       presetManager.registerCommand(opencodeConfig);
     },
 
@@ -943,6 +947,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
         output as { parts: Array<{ type: string; text?: string }> },
       );
+
+      await loopCommandHook.handleCommandExecuteBefore(
+        input as {
+          command: string;
+          sessionID: string;
+          arguments: string;
+        },
+        output as { parts: Array<{ type: string; text?: string }> },
+      );
     },
 
     'chat.headers': chatHeadersHook['chat.headers'],

+ 0 - 236
src/loop/loop-engine.ts

@@ -1,236 +0,0 @@
-import { unlinkSync } from 'node:fs';
-import type {
-  BackgroundJobBoard,
-  BackgroundJobRecord,
-} from '../utils/background-job-board';
-import type {
-  LoopDefinition,
-  LoopSession,
-  VerificationResult,
-} from './loop-session';
-import { createLoopSession, writeHistoryFile } from './loop-session';
-
-export type DispatchCallback = (
-  agent: string,
-  prompt: string,
-  contextFiles: string[],
-) => string;
-
-export interface LoopEngineCallbacks {
-  onLoopComplete?: (loopID: string, success: boolean) => void;
-  onEscalated?: (loopID: string, reason: string) => void;
-  onManualReview?: (loopID: string, reason: string) => void;
-  onArtifactWrite?: (loopID: string, artifactPath: string) => void;
-}
-
-export class LoopEngine {
-  private sessions = new Map<string, LoopSession>();
-
-  constructor(
-    private readonly jobBoard: BackgroundJobBoard,
-    private readonly callbacks: LoopEngineCallbacks,
-    private readonly dispatch: DispatchCallback,
-  ) {
-    this.jobBoard.addTerminalStateListener(this.handleTerminalJob.bind(this));
-  }
-
-  startLoop(definition: LoopDefinition): string {
-    if (
-      (definition.executeAgent as string) === (definition.verifyAgent as string)
-    ) {
-      throw new Error('executeAgent and verifyAgent must differ');
-    }
-
-    const loopID = `loop-${Date.now().toString(36)}-${Math.random()
-      .toString(36)
-      .slice(2, 8)}`;
-    const session = createLoopSession(definition, loopID);
-    this.sessions.set(loopID, session);
-    writeHistoryFile(session);
-    this.dispatchPhase(session);
-    return loopID;
-  }
-
-  resolveManualReview(loopID: string, passed: boolean, reason?: string): void {
-    const session = this.sessions.get(loopID);
-    if (!session || session.currentPhase !== 'verifying') return;
-    session.manualReviewPending = false;
-    const verification: VerificationResult = {
-      passed,
-      reason:
-        reason ?? (passed ? 'Manual review passed' : 'Manual review failed'),
-    };
-    session.history.push({
-      attemptNumber: session.attempts,
-      executionResult: 'manual review',
-      verificationResult: verification,
-    });
-    writeHistoryFile(session);
-
-    if (passed) {
-      this.finishSession(session, true);
-      return;
-    }
-
-    if (session.attempts >= session.definition.maxAttempts) {
-      this.escalate(session, 'Manual review failed, max attempts reached');
-      return;
-    }
-
-    session.attempts += 1;
-    session.currentPhase = 'executing';
-    this.dispatchPhase(session);
-  }
-
-  private dispatchPhase(session: LoopSession): void {
-    if (session.manualReviewPending) return;
-
-    if (session.currentPhase === 'executing') {
-      const prompt = `Loop ${session.loopID} attempt ${session.attempts}`;
-      const taskID = this.dispatch(
-        session.definition.executeAgent,
-        prompt,
-        session.definition.contextFiles ?? [],
-      );
-      session.activeJobID = taskID;
-      this.jobBoard.registerLaunch({
-        taskID,
-        parentSessionID: session.loopID,
-        agent: session.definition.executeAgent,
-        description: prompt,
-      });
-      return;
-    }
-
-    if (session.currentPhase === 'verifying') {
-      if (session.definition.success.type === 'manual') {
-        session.manualReviewPending = true;
-        this.callbacks.onManualReview?.(
-          session.loopID,
-          session.definition.successCriteria,
-        );
-        return;
-      }
-
-      const prompt = `Loop ${session.loopID} verification attempt ${session.attempts}`;
-      const taskID = this.dispatch(
-        session.definition.verifyAgent,
-        prompt,
-        session.definition.contextFiles ?? [],
-      );
-      session.activeJobID = taskID;
-      this.jobBoard.registerLaunch({
-        taskID,
-        parentSessionID: session.loopID,
-        agent: session.definition.verifyAgent,
-        description: prompt,
-      });
-    }
-  }
-
-  private handleTerminalJob(taskID: string): void {
-    const record = this.jobBoard.get(taskID);
-    if (!record) return;
-    const session = this.sessions.get(record.parentSessionID);
-    if (!session) return;
-    session.activeJobID = undefined;
-
-    if (record.state === 'cancelled') {
-      this.finishSession(session, false);
-      return;
-    }
-
-    if (record.state === 'error') {
-      if (this.jobBoard.hasConvergenceSignals(taskID)) {
-        this.escalate(session, 'Convergence signals exceeded');
-        return;
-      }
-      this.failSession(session, record);
-      return;
-    }
-
-    if (session.currentPhase === 'executing') {
-      session.currentPhase = 'verifying';
-      this.dispatchPhase(session);
-      return;
-    }
-
-    if (session.currentPhase === 'verifying') {
-      this.evaluateVerification(session, record);
-    }
-  }
-
-  private evaluateVerification(
-    session: LoopSession,
-    record: BackgroundJobRecord,
-  ): void {
-    const result = this.parseVerification(record.resultSummary);
-    if (result) {
-      session.history.push({
-        attemptNumber: session.attempts,
-        executionResult: record.description,
-        verificationResult: result,
-      });
-      writeHistoryFile(session);
-      if (result.passed) {
-        this.finishSession(session, true);
-        return;
-      }
-
-      if (session.attempts >= session.definition.maxAttempts) {
-        this.escalate(session, result.reason);
-        return;
-      }
-      session.attempts += 1;
-      session.currentPhase = 'executing';
-      writeHistoryFile(session);
-      this.dispatchPhase(session);
-      return;
-    }
-
-    session.currentPhase = 'executing';
-    writeHistoryFile(session);
-    this.dispatchPhase(session);
-  }
-
-  private parseVerification(raw?: string): VerificationResult | null {
-    if (!raw) return null;
-    try {
-      const parsed = JSON.parse(raw);
-      if (typeof parsed.passed !== 'boolean') return null;
-      if (typeof parsed.reason !== 'string') return null;
-      return {
-        passed: parsed.passed,
-        reason: parsed.reason,
-        suggestedFix: parsed.suggestedFix,
-      };
-    } catch {
-      return null;
-    }
-  }
-
-  private escalate(session: LoopSession, reason: string): void {
-    session.currentPhase = 'escalated';
-    this.cleanupSession(session);
-    this.callbacks.onEscalated?.(session.loopID, reason);
-  }
-
-  private failSession(session: LoopSession, record: BackgroundJobRecord): void {
-    this.escalate(session, record.lastStatusError ?? 'Execution failed');
-  }
-
-  private finishSession(session: LoopSession, success: boolean): void {
-    session.currentPhase = success ? 'done' : 'escalated';
-    this.cleanupSession(session);
-    this.callbacks.onLoopComplete?.(session.loopID, success);
-  }
-
-  private cleanupSession(session: LoopSession): void {
-    try {
-      unlinkSync(session.historyFilePath);
-    } catch {
-      // best effort
-    }
-    this.sessions.delete(session.loopID);
-  }
-}

+ 86 - 0
src/loop/loop-session.test.ts

@@ -0,0 +1,86 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  compactAttempt,
+  createLoopSession,
+  type LoopDefinition,
+  loopDirname,
+} from './loop-session';
+
+function testDef(overrides?: Partial<LoopDefinition>): LoopDefinition {
+  return {
+    goal: 'test goal',
+    successCriteria: 'it works',
+    success: { type: 'test', command: 'bun test' },
+    maxAttempts: 3,
+    executeAgent: 'fixer',
+    verifyAgent: 'oracle',
+    ...overrides,
+  };
+}
+
+describe('loopDirname', () => {
+  test('creates human-readable dir name with short ID', () => {
+    const name = loopDirname('loop-mqwo5ddt', 'Fix typescript errors');
+    expect(name).toBe('fix-typescript-errors-mqwo5ddt');
+  });
+
+  test('slugifies the goal text', () => {
+    const name = loopDirname('loop-abc-123', 'Fix TypeScript & ESLint errors!');
+    expect(name).toBe('fix-typescript-eslint-errors-123');
+  });
+
+  test('truncates long goals', () => {
+    const longGoal = 'a'.repeat(50);
+    const name = loopDirname('xyz-999', longGoal);
+    expect(name.length).toBeLessThan(60);
+  });
+});
+
+describe('createLoopSession', () => {
+  test('creates a session with executing phase and attempt 1', () => {
+    const def = testDef();
+    const session = createLoopSession(def, 'loop-test-1');
+
+    expect(session.loopID).toBe('loop-test-1');
+    expect(session.definition).toBe(def);
+    expect(session.currentPhase).toBe('executing');
+    expect(session.attempts).toBe(1);
+    expect(session.history).toEqual([]);
+    expect(session.activeJobID).toBeUndefined();
+    expect(session.manualReviewPending).toBe(false);
+    expect(session.historyDir).toContain('test-goal');
+  });
+});
+
+describe('compactAttempt', () => {
+  test('formats a passed attempt', () => {
+    const result = compactAttempt({
+      attemptNumber: 1,
+      executionResult: 'bun test',
+      verificationResult: { passed: true, reason: 'all green' },
+    });
+    expect(result).toContain('## Attempt 1');
+    expect(result).toContain('**Outcome:** PASS');
+    expect(result).toContain('### Execution Result');
+  });
+
+  test('formats a failed attempt with reason', () => {
+    const result = compactAttempt({
+      attemptNumber: 2,
+      executionResult: 'bun test',
+      verificationResult: { passed: false, reason: 'tests failed' },
+    });
+    expect(result).toContain('## Attempt 2');
+    expect(result).toContain('FAIL: tests failed');
+  });
+
+  test('includes artifacts when present', () => {
+    const result = compactAttempt({
+      attemptNumber: 1,
+      executionResult: 'built',
+      verificationResult: { passed: true, reason: 'ok' },
+      artifactPaths: ['src/output.ts', 'src/output.test.ts'],
+    });
+    expect(result).toContain('artifacts: src/output.ts, src/output.test.ts');
+  });
+});

+ 46 - 18
src/loop/loop-session.ts

@@ -1,6 +1,22 @@
-import { writeFileSync } from 'node:fs';
+import { mkdirSync, writeFileSync } from 'node:fs';
 import { join } from 'node:path';
 
+const HISTORY_DIR = join(process.cwd(), '.opencode', 'loop-history');
+
+function slugify(text: string): string {
+  return text
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/(^-|-$)/g, '')
+    .slice(0, 40);
+}
+
+export function loopDirname(loopID: string, goal: string): string {
+  const parts = loopID.split('-');
+  const shortID = parts[parts.length - 1] ?? loopID;
+  return `${slugify(goal)}-${shortID}`;
+}
+
 export type LoopPhase =
   | 'executing'
   | 'verifying'
@@ -29,6 +45,7 @@ export interface LoopDefinition {
   executeAgent: ExecuteAgent;
   verifyAgent: VerifyAgent;
   contextFiles?: string[];
+  parentSessionID?: string;
 }
 
 export type VerificationResult =
@@ -49,7 +66,7 @@ export interface LoopSession {
   attempts: number;
   activeJobID?: string;
   history: AttemptRecord[];
-  historyFilePath: string;
+  historyDir: string;
   manualReviewPending: boolean;
 }
 
@@ -57,33 +74,44 @@ export function createLoopSession(
   definition: LoopDefinition,
   loopID: string,
 ): LoopSession {
-  const historyFilePath = join(process.cwd(), `.loop-history-${loopID}.md`);
+  const dir = join(HISTORY_DIR, loopDirname(loopID, definition.goal));
   return {
     loopID,
     definition,
     currentPhase: 'executing',
     attempts: 1,
     history: [],
-    historyFilePath,
     manualReviewPending: false,
+    historyDir: dir,
   };
 }
 
-export function compactHistory(history: AttemptRecord[]): string {
-  if (history.length === 0) return '';
-  const lines = history.map((attempt, index) => {
-    const outcome = attempt.verificationResult.passed
-      ? 'PASS'
-      : `FAIL: ${attempt.verificationResult.reason}`;
-    const artifacts = attempt.artifactPaths?.length
-      ? ` → artifacts: ${attempt.artifactPaths.join(', ')}`
-      : '';
-    return `[Attempt ${index + 1}] ${outcome}${artifacts}`;
-  });
-  return `# Loop Attempt History\n\n${lines.join('\n')}\n`;
+export function compactAttempt(attempt: AttemptRecord): string {
+  const outcome = attempt.verificationResult.passed
+    ? 'PASS'
+    : `FAIL: ${attempt.verificationResult.reason}`;
+  const artifacts = attempt.artifactPaths?.length
+    ? `\n  → artifacts: ${attempt.artifactPaths.join(', ')}`
+    : '';
+  return `## Attempt ${attempt.attemptNumber}
+
+**Outcome:** ${outcome}${artifacts}
+
+### Execution Result
+\`\`\`
+${attempt.executionResult}
+\`\`\`
+`;
 }
 
 export function writeHistoryFile(session: LoopSession): void {
-  const content = compactHistory(session.history);
-  writeFileSync(session.historyFilePath, content, { encoding: 'utf-8' });
+  const lastAttempt = session.history.at(-1);
+  if (!lastAttempt) return;
+  const attemptFile = join(
+    session.historyDir,
+    `history-${String(session.attempts).padStart(3, '0')}.md`,
+  );
+  mkdirSync(session.historyDir, { recursive: true });
+  const content = compactAttempt(lastAttempt);
+  writeFileSync(attemptFile, content, { encoding: 'utf-8' });
 }

+ 30 - 0
src/skills/loop-engineering/SKILL.md

@@ -0,0 +1,30 @@
+---
+name: loop-engineering
+description: Loop engineering runtime Grill + Monitor
+---
+
+# Loop Engineering Skill
+
+## Grill (orchestrator interview)
+
+1. Goal: "What are you trying to accomplish?"
+2. Success criteria: "Describe how we know the loop succeeded."
+3. Success type: choose from `test`, `build`, `lint`, `command`, `fileExists`, `oracle`, `observer`, `manual`. For CLI steps provide `successCommand`; for file detection provide `successPath`.
+4. Execute agent: fixer / designer / explorer / librarian
+5. Verify agent: oracle / observer / test
+6. Max attempts (default 3)
+7. Optional context files: which files or directories should be read before execution?
+
+## Loop Monitor
+
+- Listen to callbacks:
+  - `onLoopComplete(loopID, success)` → report final outcome
+  - `onEscalated(loopID, reason)` → escalate to human
+  - `onManualReview(loopID, reason)` → prompt human to approve/fail and call `resolveManualReview(loopID, passed, reason)`
+- Show current state and attempt count on each callback
+- For manual verification, present the failure reason before asking for pass/fail
+- If human forces cancellation, call `cancel(loopID)` through the orchestrator
+
+## Notes
+- Manual verification is the minimal on-ramp (autoresearch pattern). It pauses the loop until `resolveManualReview` is called. Do not auto-resolve.
+- BackgroundJobBoard signals (totalErrors, timeoutCount) are already baked into the runtime.