From bcf38a5530245605de5a0294d2f7d6057e274155 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:03:28 +0900 Subject: [PATCH] takt: 257/add-agent-usecase-structured-o --- src/__tests__/agent-usecases.test.ts | 38 ++++++++++ src/__tests__/judgment-detector.test.ts | 70 ------------------ src/__tests__/public-api-exports.test.ts | 40 +++++++++++ src/core/piece/agent-usecases.ts | 79 ++------------------- src/core/piece/engine/OptionsBuilder.ts | 7 -- src/core/piece/engine/TeamLeaderRunner.ts | 4 +- src/core/piece/engine/task-decomposer.ts | 41 +---------- src/core/piece/judgment/JudgmentDetector.ts | 45 ------------ src/core/piece/judgment/index.ts | 8 --- src/core/piece/part-definition-validator.ts | 41 +++++++++++ src/core/piece/phase-runner.ts | 4 +- src/index.ts | 15 ---- src/infra/claude/utils.ts | 19 +---- src/infra/codex/client.ts | 4 +- src/infra/opencode/client.ts | 9 +-- src/shared/utils/ruleIndex.ts | 15 ++++ 16 files changed, 158 insertions(+), 281 deletions(-) delete mode 100644 src/__tests__/judgment-detector.test.ts create mode 100644 src/__tests__/public-api-exports.test.ts delete mode 100644 src/core/piece/judgment/JudgmentDetector.ts delete mode 100644 src/core/piece/judgment/index.ts create mode 100644 src/core/piece/part-definition-validator.ts create mode 100644 src/shared/utils/ruleIndex.ts diff --git a/src/__tests__/agent-usecases.test.ts b/src/__tests__/agent-usecases.test.ts index d8e3f11..570f368 100644 --- a/src/__tests__/agent-usecases.test.ts +++ b/src/__tests__/agent-usecases.test.ts @@ -86,6 +86,22 @@ describe('agent-usecases', () => { expect(detectJudgeIndex).toHaveBeenCalledWith('[JUDGE:2]'); }); + it('evaluateCondition は runAgent が done 以外なら -1 を返す', async () => { + vi.mocked(runAgent).mockResolvedValue({ + persona: 'tester', + status: 'error', + content: 'failed', + timestamp: new Date('2026-02-12T00:00:00Z'), + }); + + const result = await evaluateCondition('agent output', [ + { index: 0, text: 'first' }, + ], { cwd: '/repo' }); + + expect(result).toBe(-1); + expect(detectJudgeIndex).not.toHaveBeenCalled(); + }); + it('judgeStatus は単一ルール時に auto_select を返す', async () => { const result = await judgeStatus('instruction', [{ condition: 'always', next: 'done' }], { cwd: '/repo', @@ -96,6 +112,13 @@ describe('agent-usecases', () => { expect(runAgent).not.toHaveBeenCalled(); }); + it('judgeStatus はルールが空ならエラー', async () => { + await expect(judgeStatus('instruction', [], { + cwd: '/repo', + movementName: 'review', + })).rejects.toThrow('judgeStatus requires at least one rule'); + }); + it('judgeStatus は構造化出力 step を採用する', async () => { vi.mocked(runAgent).mockResolvedValue(doneResponse('x', { step: 2 })); @@ -141,6 +164,21 @@ describe('agent-usecases', () => { expect(runAgent).toHaveBeenCalledTimes(2); }); + it('judgeStatus は全ての判定に失敗したらエラー', async () => { + vi.mocked(runAgent) + .mockResolvedValueOnce(doneResponse('no match')) + .mockResolvedValueOnce(doneResponse('still no match')); + vi.mocked(detectJudgeIndex).mockReturnValue(-1); + + await expect(judgeStatus('instruction', [ + { condition: 'a', next: 'one' }, + { condition: 'b', next: 'two' }, + ], { + cwd: '/repo', + movementName: 'review', + })).rejects.toThrow('Status not found for movement "review"'); + }); + it('decomposeTask は構造化出力 parts を返す', async () => { vi.mocked(runAgent).mockResolvedValue(doneResponse('x', { parts: [ diff --git a/src/__tests__/judgment-detector.test.ts b/src/__tests__/judgment-detector.test.ts deleted file mode 100644 index 1bd198c..0000000 --- a/src/__tests__/judgment-detector.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Test for JudgmentDetector - */ - -import { describe, it, expect } from 'vitest'; -import { JudgmentDetector } from '../core/piece/judgment/JudgmentDetector.js'; - -describe('JudgmentDetector', () => { - describe('detect', () => { - it('should detect tag in simple response', () => { - const result = JudgmentDetector.detect('[ARCH-REVIEW:1]'); - expect(result.success).toBe(true); - expect(result.tag).toBe('[ARCH-REVIEW:1]'); - }); - - it('should detect tag with surrounding text', () => { - const result = JudgmentDetector.detect('Based on the review, I choose [MOVEMENT:2] because...'); - expect(result.success).toBe(true); - expect(result.tag).toBe('[MOVEMENT:2]'); - }); - - it('should detect tag with hyphenated movement name', () => { - const result = JudgmentDetector.detect('[AI-ANTIPATTERN-REVIEW:1]'); - expect(result.success).toBe(true); - expect(result.tag).toBe('[AI-ANTIPATTERN-REVIEW:1]'); - }); - - it('should detect tag with underscored movement name', () => { - const result = JudgmentDetector.detect('[AI_REVIEW:1]'); - expect(result.success).toBe(true); - expect(result.tag).toBe('[AI_REVIEW:1]'); - }); - - it('should detect "判断できない" (Japanese)', () => { - const result = JudgmentDetector.detect('判断できない:情報が不足しています'); - expect(result.success).toBe(false); - expect(result.reason).toBe('Conductor explicitly stated it cannot judge'); - }); - - it('should detect "Cannot determine" (English)', () => { - const result = JudgmentDetector.detect('Cannot determine: Insufficient information'); - expect(result.success).toBe(false); - expect(result.reason).toBe('Conductor explicitly stated it cannot judge'); - }); - - it('should detect "unable to judge"', () => { - const result = JudgmentDetector.detect('I am unable to judge based on the provided information.'); - expect(result.success).toBe(false); - expect(result.reason).toBe('Conductor explicitly stated it cannot judge'); - }); - - it('should fail when no tag and no explicit "cannot judge"', () => { - const result = JudgmentDetector.detect('This is a response without a tag or explicit statement.'); - expect(result.success).toBe(false); - expect(result.reason).toBe('No tag found and no explicit "cannot judge" statement'); - }); - - it('should fail on empty response', () => { - const result = JudgmentDetector.detect(''); - expect(result.success).toBe(false); - expect(result.reason).toBe('No tag found and no explicit "cannot judge" statement'); - }); - - it('should detect first tag when multiple tags exist', () => { - const result = JudgmentDetector.detect('[MOVEMENT:1] or [MOVEMENT:2]'); - expect(result.success).toBe(true); - expect(result.tag).toBe('[MOVEMENT:1]'); - }); - }); -}); diff --git a/src/__tests__/public-api-exports.test.ts b/src/__tests__/public-api-exports.test.ts new file mode 100644 index 0000000..88de2d5 --- /dev/null +++ b/src/__tests__/public-api-exports.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +describe('public API exports', () => { + it('should expose piece usecases and engine public APIs', async () => { + // Given: パッケージの公開API + const api = await import('../index.js'); + + // When: 主要なユースケース関数とエンジン公開APIを参照する + // Then: 必要な公開シンボルが利用できる + expect(typeof api.executeAgent).toBe('function'); + expect(typeof api.generateReport).toBe('function'); + expect(typeof api.executePart).toBe('function'); + expect(typeof api.judgeStatus).toBe('function'); + expect(typeof api.evaluateCondition).toBe('function'); + expect(typeof api.decomposeTask).toBe('function'); + + expect(typeof api.PieceEngine).toBe('function'); + expect(typeof api.createInitialState).toBe('function'); + expect(typeof api.addUserInput).toBe('function'); + expect(typeof api.getPreviousOutput).toBe('function'); + expect(api.COMPLETE_MOVEMENT).toBeDefined(); + expect(api.ABORT_MOVEMENT).toBeDefined(); + }); + + it('should not expose internal engine implementation details', async () => { + // Given: パッケージの公開API + const api = await import('../index.js'); + + // When: 非公開にすべき内部シンボルの有無を確認する + // Then: 内部実装詳細は公開されていない + expect('AgentRunner' in api).toBe(false); + expect('RuleEvaluator' in api).toBe(false); + expect('AggregateEvaluator' in api).toBe(false); + expect('evaluateAggregateConditions' in api).toBe(false); + expect('needsStatusJudgmentPhase' in api).toBe(false); + expect('StatusJudgmentBuilder' in api).toBe(false); + expect('buildEditRule' in api).toBe(false); + expect('detectRuleIndex' in api).toBe(false); + }); +}); diff --git a/src/core/piece/agent-usecases.ts b/src/core/piece/agent-usecases.ts index c4d58a4..0a0fe5e 100644 --- a/src/core/piece/agent-usecases.ts +++ b/src/core/piece/agent-usecases.ts @@ -3,8 +3,8 @@ import { runAgent, type RunAgentOptions } from '../../agents/runner.js'; import { detectJudgeIndex, buildJudgePrompt } from '../../agents/judge-utils.js'; import { parseParts } from './engine/task-decomposer.js'; import { loadJudgmentSchema, loadEvaluationSchema, loadDecompositionSchema } from './schema-loader.js'; - -export type UsecaseOptions = RunAgentOptions; +import { detectRuleIndex } from '../../shared/utils/ruleIndex.js'; +import { ensureUniquePartIds, parsePartDefinitionEntry } from './part-definition-validator.js'; export interface JudgeStatusOptions { cwd: string; @@ -29,18 +29,6 @@ export interface DecomposeTaskOptions { provider?: 'claude' | 'codex' | 'opencode' | 'mock'; } -function detectRuleIndex(content: string, movementName: string): number { - const tag = movementName.toUpperCase(); - const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi'); - const matches = [...content.matchAll(regex)]; - const match = matches.at(-1); - if (match?.[1]) { - const index = Number.parseInt(match[1], 10) - 1; - return index >= 0 ? index : -1; - } - return -1; -} - function toPartDefinitions(raw: unknown, maxParts: number): PartDefinition[] { if (!Array.isArray(raw)) { throw new Error('Structured output "parts" must be an array'); @@ -52,47 +40,8 @@ function toPartDefinitions(raw: unknown, maxParts: number): PartDefinition[] { throw new Error(`Structured output produced too many parts: ${raw.length} > ${maxParts}`); } - const parts: PartDefinition[] = raw.map((entry, index) => { - if (typeof entry !== 'object' || entry == null || Array.isArray(entry)) { - throw new Error(`Part[${index}] must be an object`); - } - const row = entry as Record; - const id = row.id; - const title = row.title; - const instruction = row.instruction; - const timeoutMs = row.timeout_ms; - - if (typeof id !== 'string' || id.trim().length === 0) { - throw new Error(`Part[${index}] "id" must be a non-empty string`); - } - if (typeof title !== 'string' || title.trim().length === 0) { - throw new Error(`Part[${index}] "title" must be a non-empty string`); - } - if (typeof instruction !== 'string' || instruction.trim().length === 0) { - throw new Error(`Part[${index}] "instruction" must be a non-empty string`); - } - if ( - timeoutMs != null - && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0) - ) { - throw new Error(`Part[${index}] "timeout_ms" must be a positive integer`); - } - - return { - id, - title, - instruction, - timeoutMs: timeoutMs as number | undefined, - }; - }); - - const seen = new Set(); - for (const part of parts) { - if (seen.has(part.id)) { - throw new Error(`Duplicate part id: ${part.id}`); - } - seen.add(part.id); - } + const parts: PartDefinition[] = raw.map((entry, index) => parsePartDefinitionEntry(entry, index)); + ensureUniquePartIds(parts); return parts; } @@ -100,26 +49,12 @@ function toPartDefinitions(raw: unknown, maxParts: number): PartDefinition[] { export async function executeAgent( persona: string | undefined, instruction: string, - options: UsecaseOptions, -): Promise { - return runAgent(persona, instruction, options); -} - -export async function generateReport( - persona: string | undefined, - instruction: string, - options: UsecaseOptions, -): Promise { - return runAgent(persona, instruction, options); -} - -export async function executePart( - persona: string | undefined, - instruction: string, - options: UsecaseOptions, + options: RunAgentOptions, ): Promise { return runAgent(persona, instruction, options); } +export const generateReport = executeAgent; +export const executePart = executeAgent; export async function evaluateCondition( agentOutput: string, diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index cdfdcee..f6cf81d 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -1,10 +1,3 @@ -/** - * Builds RunAgentOptions for different execution phases. - * - * Centralizes the option construction logic that was previously - * scattered across PieceEngine methods. - */ - import { join } from 'node:path'; import type { PieceMovement, PieceState, Language } from '../../models/types.js'; import type { RunAgentOptions } from '../../../agents/runner.js'; diff --git a/src/core/piece/engine/TeamLeaderRunner.ts b/src/core/piece/engine/TeamLeaderRunner.ts index f082396..9b92eaf 100644 --- a/src/core/piece/engine/TeamLeaderRunner.ts +++ b/src/core/piece/engine/TeamLeaderRunner.ts @@ -5,7 +5,7 @@ import type { PartDefinition, PartResult, } from '../../models/types.js'; -import { decomposeTask, executePart } from '../agent-usecases.js'; +import { decomposeTask, executeAgent } from '../agent-usecases.js'; import { detectMatchedRule } from '../evaluation/index.js'; import { buildSessionKey } from '../session-key.js'; import { ParallelLogger } from './parallel-logger.js'; @@ -232,7 +232,7 @@ export class TeamLeaderRunner { : { ...baseOptions, abortSignal: signal }; try { - const response = await executePart(partMovement.persona, part.instruction, options); + const response = await executeAgent(partMovement.persona, part.instruction, options); updatePersonaSession(buildSessionKey(partMovement), response.sessionId); return { part, diff --git a/src/core/piece/engine/task-decomposer.ts b/src/core/piece/engine/task-decomposer.ts index 5754fbe..34ffc6f 100644 --- a/src/core/piece/engine/task-decomposer.ts +++ b/src/core/piece/engine/task-decomposer.ts @@ -1,4 +1,5 @@ import type { PartDefinition } from '../../models/part.js'; +import { ensureUniquePartIds, parsePartDefinitionEntry } from '../part-definition-validator.js'; const JSON_CODE_BLOCK_REGEX = /```json\s*([\s\S]*?)```/g; @@ -24,36 +25,6 @@ function parseJsonBlock(content: string): unknown { } } -function assertString(value: unknown, fieldName: string, index: number): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`Part[${index}] "${fieldName}" must be a non-empty string`); - } - return value; -} - -function parsePartEntry(entry: unknown, index: number): PartDefinition { - if (typeof entry !== 'object' || entry == null || Array.isArray(entry)) { - throw new Error(`Part[${index}] must be an object`); - } - - const raw = entry as Record; - const id = assertString(raw.id, 'id', index); - const title = assertString(raw.title, 'title', index); - const instruction = assertString(raw.instruction, 'instruction', index); - - const timeoutMs = raw.timeout_ms; - if (timeoutMs != null && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0)) { - throw new Error(`Part[${index}] "timeout_ms" must be a positive integer`); - } - - return { - id, - title, - instruction, - timeoutMs: timeoutMs as number | undefined, - }; -} - export function parseParts(content: string, maxParts: number): PartDefinition[] { const parsed = parseJsonBlock(content); if (!Array.isArray(parsed)) { @@ -66,14 +37,8 @@ export function parseParts(content: string, maxParts: number): PartDefinition[] throw new Error(`Team leader produced too many parts: ${parsed.length} > ${maxParts}`); } - const parts = parsed.map((entry, index) => parsePartEntry(entry, index)); - const ids = new Set(); - for (const part of parts) { - if (ids.has(part.id)) { - throw new Error(`Duplicate part id: ${part.id}`); - } - ids.add(part.id); - } + const parts = parsed.map((entry, index) => parsePartDefinitionEntry(entry, index)); + ensureUniquePartIds(parts); return parts; } diff --git a/src/core/piece/judgment/JudgmentDetector.ts b/src/core/piece/judgment/JudgmentDetector.ts deleted file mode 100644 index a00a5da..0000000 --- a/src/core/piece/judgment/JudgmentDetector.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Detect judgment result from conductor's response. - */ -export interface JudgmentResult { - success: boolean; - tag?: string; // e.g., "[ARCH-REVIEW:1]" - reason?: string; -} - -export class JudgmentDetector { - private static readonly TAG_PATTERN = /\[([A-Z_-]+):(\d+)\]/; - private static readonly CANNOT_JUDGE_PATTERNS = [ - /判断できない/i, - /cannot\s+determine/i, - /unable\s+to\s+judge/i, - /insufficient\s+information/i, - ]; - - static detect(response: string): JudgmentResult { - // 1. タグ検出 - const tagMatch = response.match(this.TAG_PATTERN); - if (tagMatch) { - return { - success: true, - tag: tagMatch[0], // e.g., "[ARCH-REVIEW:1]" - }; - } - - // 2. 「判断できない」検出 - for (const pattern of this.CANNOT_JUDGE_PATTERNS) { - if (pattern.test(response)) { - return { - success: false, - reason: 'Conductor explicitly stated it cannot judge', - }; - } - } - - // 3. タグも「判断できない」もない → 失敗 - return { - success: false, - reason: 'No tag found and no explicit "cannot judge" statement', - }; - } -} diff --git a/src/core/piece/judgment/index.ts b/src/core/piece/judgment/index.ts deleted file mode 100644 index c5e6487..0000000 --- a/src/core/piece/judgment/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Judgment module exports - */ - -export { - JudgmentDetector, - type JudgmentResult, -} from './JudgmentDetector.js'; diff --git a/src/core/piece/part-definition-validator.ts b/src/core/piece/part-definition-validator.ts new file mode 100644 index 0000000..bdd2939 --- /dev/null +++ b/src/core/piece/part-definition-validator.ts @@ -0,0 +1,41 @@ +import type { PartDefinition } from '../models/part.js'; + +function assertNonEmptyString(value: unknown, fieldName: string, index: number): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Part[${index}] "${fieldName}" must be a non-empty string`); + } + return value; +} + +export function parsePartDefinitionEntry(entry: unknown, index: number): PartDefinition { + if (typeof entry !== 'object' || entry == null || Array.isArray(entry)) { + throw new Error(`Part[${index}] must be an object`); + } + + const raw = entry as Record; + const id = assertNonEmptyString(raw.id, 'id', index); + const title = assertNonEmptyString(raw.title, 'title', index); + const instruction = assertNonEmptyString(raw.instruction, 'instruction', index); + + const timeoutMs = raw.timeout_ms; + if (timeoutMs != null && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0)) { + throw new Error(`Part[${index}] "timeout_ms" must be a positive integer`); + } + + return { + id, + title, + instruction, + timeoutMs: timeoutMs as number | undefined, + }; +} + +export function ensureUniquePartIds(parts: PartDefinition[]): void { + const ids = new Set(); + for (const part of parts) { + if (ids.has(part.id)) { + throw new Error(`Duplicate part id: ${part.id}`); + } + ids.add(part.id); + } +} diff --git a/src/core/piece/phase-runner.ts b/src/core/piece/phase-runner.ts index 7105bf8..7059d2a 100644 --- a/src/core/piece/phase-runner.ts +++ b/src/core/piece/phase-runner.ts @@ -12,7 +12,7 @@ import type { PhaseName } from './types.js'; import type { RunAgentOptions } from '../../agents/runner.js'; import { ReportInstructionBuilder } from './instruction/ReportInstructionBuilder.js'; import { hasTagBasedRules, getReportFiles } from './evaluation/rule-utils.js'; -import { generateReport } from './agent-usecases.js'; +import { executeAgent } from './agent-usecases.js'; import { createLogger } from '../../shared/utils/index.js'; import { buildSessionKey } from './session-key.js'; export { runStatusJudgmentPhase } from './status-judgment-phase.js'; @@ -214,7 +214,7 @@ async function runSingleReportAttempt( let response: AgentResponse; try { - response = await generateReport(step.persona, instruction, options); + response = await executeAgent(step.persona, instruction, options); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); ctx.onPhaseComplete?.(step, 2, 'report', '', 'error', errorMsg); diff --git a/src/index.ts b/src/index.ts index ec9cbb8..e2383ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,9 +87,6 @@ export type { export { CodexClient, mapToCodexSandboxMode } from './infra/codex/index.js'; export type { CodexCallOptions, CodexSandboxMode } from './infra/codex/index.js'; -// Agent execution -export * from './agents/index.js'; - // Piece engine export { PieceEngine, @@ -107,12 +104,6 @@ export { InstructionBuilder, isOutputContractItem, ReportInstructionBuilder, - StatusJudgmentBuilder, - buildEditRule, - RuleEvaluator, - evaluateAggregateConditions, - AggregateEvaluator, - needsStatusJudgmentPhase, executeAgent, generateReport, executePart, @@ -129,13 +120,7 @@ export type { PieceEngineOptions, LoopCheckResult, ProviderType, - RuleMatch, - RuleEvaluatorContext, JudgeStatusResult, - ReportInstructionContext, - StatusJudgmentContext, - InstructionContext, - StatusRulesComponents, BlockedHandlerResult, } from './core/piece/index.js'; diff --git a/src/infra/claude/utils.ts b/src/infra/claude/utils.ts index 7255a24..20322fd 100644 --- a/src/infra/claude/utils.ts +++ b/src/infra/claude/utils.ts @@ -3,24 +3,9 @@ * * Stateless helpers for rule detection and regex safety validation. */ +import { detectRuleIndex } from '../../shared/utils/ruleIndex.js'; -/** - * Detect rule index from numbered tag pattern [STEP_NAME:N]. - * Returns 0-based rule index, or -1 if no match. - * - * Example: detectRuleIndex("... [PLAN:2] ...", "plan") → 1 - */ -export function detectRuleIndex(content: string, movementName: string): number { - const tag = movementName.toUpperCase(); - const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi'); - const matches = [...content.matchAll(regex)]; - const match = matches.at(-1); - if (match?.[1]) { - const index = Number.parseInt(match[1], 10) - 1; - return index >= 0 ? index : -1; - } - return -1; -} +export { detectRuleIndex }; /** Validate regex pattern for ReDoS safety */ export function isRegexSafe(pattern: string): boolean { diff --git a/src/infra/codex/client.ts b/src/infra/codex/client.ts index 48db412..0859842 100644 --- a/src/infra/codex/client.ts +++ b/src/infra/codex/client.ts @@ -156,7 +156,9 @@ export class CodexClient { if (options.outputSchema) { runOptions.outputSchema = options.outputSchema; } - const { events } = await thread.runStreamed(fullPrompt, runOptions as never); + // Codex SDK types do not yet expose outputSchema even though runtime accepts it. + const runStreamedOptions = runOptions as unknown as Parameters[1]; + const { events } = await thread.runStreamed(fullPrompt, runStreamedOptions); resetIdleTimeout(); diag.onConnected(); diff --git a/src/infra/opencode/client.ts b/src/infra/opencode/client.ts index 3366cb5..17133b7 100644 --- a/src/infra/opencode/client.ts +++ b/src/infra/opencode/client.ts @@ -343,10 +343,11 @@ export class OpenCodeClient { }; } - await client.session.promptAsync( - promptPayload as never, - { signal: streamAbortController.signal }, - ); + // OpenCode SDK types do not yet expose outputFormat even though runtime accepts it. + const promptPayloadForSdk = promptPayload as unknown as Parameters[0]; + await client.session.promptAsync(promptPayloadForSdk, { + signal: streamAbortController.signal, + }); emitInit(options.onStream, options.model, sessionId); diff --git a/src/shared/utils/ruleIndex.ts b/src/shared/utils/ruleIndex.ts new file mode 100644 index 0000000..e0758c4 --- /dev/null +++ b/src/shared/utils/ruleIndex.ts @@ -0,0 +1,15 @@ +/** + * Detect rule index from numbered tag pattern [STEP_NAME:N]. + * Returns 0-based rule index, or -1 if no match. + */ +export function detectRuleIndex(content: string, movementName: string): number { + const tag = movementName.toUpperCase(); + const regex = new RegExp(`\\[${tag}:(\\d+)\\]`, 'gi'); + const matches = [...content.matchAll(regex)]; + const match = matches.at(-1); + if (match?.[1]) { + const index = Number.parseInt(match[1], 10) - 1; + return index >= 0 ? index : -1; + } + return -1; +}