takt: github-issue-125-claude-agent (#133)

This commit is contained in:
nrs 2026-02-08 08:11:05 +09:00 committed by GitHub
parent d9ab76f08b
commit ffc151cd8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 641 additions and 147 deletions

View File

@ -447,6 +447,131 @@ movements:
}); });
}); });
describe('Piece Loader IT: mcp_servers parsing', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should parse mcp_servers from YAML to PieceMovement.mcpServers', () => {
const piecesDir = join(testDir, '.takt', 'pieces');
mkdirSync(piecesDir, { recursive: true });
writeFileSync(join(piecesDir, 'with-mcp.yaml'), `
name: with-mcp
description: Piece with MCP servers
max_iterations: 5
initial_movement: e2e-test
movements:
- name: e2e-test
persona: coder
mcp_servers:
playwright:
command: npx
args: ["-y", "@anthropic-ai/mcp-server-playwright"]
allowed_tools:
- Read
- Bash
- mcp__playwright__*
rules:
- condition: Done
next: COMPLETE
instruction: "Run E2E tests"
`);
const config = loadPiece('with-mcp', testDir);
expect(config).not.toBeNull();
const e2eStep = config!.movements.find((s) => s.name === 'e2e-test');
expect(e2eStep).toBeDefined();
expect(e2eStep!.mcpServers).toEqual({
playwright: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
},
});
});
it('should allow movement without mcp_servers', () => {
const piecesDir = join(testDir, '.takt', 'pieces');
mkdirSync(piecesDir, { recursive: true });
writeFileSync(join(piecesDir, 'no-mcp.yaml'), `
name: no-mcp
description: Piece without MCP servers
max_iterations: 5
initial_movement: implement
movements:
- name: implement
persona: coder
rules:
- condition: Done
next: COMPLETE
instruction: "Implement the feature"
`);
const config = loadPiece('no-mcp', testDir);
expect(config).not.toBeNull();
const implementStep = config!.movements.find((s) => s.name === 'implement');
expect(implementStep).toBeDefined();
expect(implementStep!.mcpServers).toBeUndefined();
});
it('should parse mcp_servers with multiple servers and transports', () => {
const piecesDir = join(testDir, '.takt', 'pieces');
mkdirSync(piecesDir, { recursive: true });
writeFileSync(join(piecesDir, 'multi-mcp.yaml'), `
name: multi-mcp
description: Piece with multiple MCP servers
max_iterations: 5
initial_movement: test
movements:
- name: test
persona: coder
mcp_servers:
playwright:
command: npx
args: ["-y", "@anthropic-ai/mcp-server-playwright"]
remote-api:
type: http
url: http://localhost:3000/mcp
headers:
Authorization: "Bearer token123"
rules:
- condition: Done
next: COMPLETE
instruction: "Run tests"
`);
const config = loadPiece('multi-mcp', testDir);
expect(config).not.toBeNull();
const testStep = config!.movements.find((s) => s.name === 'test');
expect(testStep).toBeDefined();
expect(testStep!.mcpServers).toEqual({
playwright: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
},
'remote-api': {
type: 'http',
url: 'http://localhost:3000/mcp',
headers: { Authorization: 'Bearer token123' },
},
});
});
});
describe('Piece Loader IT: invalid YAML handling', () => { describe('Piece Loader IT: invalid YAML handling', () => {
let testDir: string; let testDir: string;

View File

@ -8,6 +8,7 @@ import {
StatusSchema, StatusSchema,
PermissionModeSchema, PermissionModeSchema,
PieceConfigRawSchema, PieceConfigRawSchema,
McpServerConfigSchema,
CustomAgentConfigSchema, CustomAgentConfigSchema,
GlobalConfigSchema, GlobalConfigSchema,
} from '../core/models/index.js'; } from '../core/models/index.js';
@ -143,6 +144,210 @@ describe('PieceConfigRawSchema', () => {
expect(() => PieceConfigRawSchema.parse(config)).toThrow(); expect(() => PieceConfigRawSchema.parse(config)).toThrow();
}); });
it('should parse movement with stdio mcp_servers', () => {
const config = {
name: 'test-piece',
movements: [
{
name: 'e2e-test',
persona: 'coder',
mcp_servers: {
playwright: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
},
},
allowed_tools: ['mcp__playwright__*'],
instruction: '{task}',
},
],
};
const result = PieceConfigRawSchema.parse(config);
expect(result.movements![0]?.mcp_servers).toEqual({
playwright: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
},
});
});
it('should parse movement with sse mcp_servers', () => {
const config = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
mcp_servers: {
remote: {
type: 'sse',
url: 'http://localhost:8080/sse',
headers: { Authorization: 'Bearer token' },
},
},
instruction: '{task}',
},
],
};
const result = PieceConfigRawSchema.parse(config);
expect(result.movements![0]?.mcp_servers).toEqual({
remote: {
type: 'sse',
url: 'http://localhost:8080/sse',
headers: { Authorization: 'Bearer token' },
},
});
});
it('should parse movement with http mcp_servers', () => {
const config = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
mcp_servers: {
api: {
type: 'http',
url: 'http://localhost:3000/mcp',
},
},
instruction: '{task}',
},
],
};
const result = PieceConfigRawSchema.parse(config);
expect(result.movements![0]?.mcp_servers).toEqual({
api: {
type: 'http',
url: 'http://localhost:3000/mcp',
},
});
});
it('should allow omitting mcp_servers', () => {
const config = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
instruction: '{task}',
},
],
};
const result = PieceConfigRawSchema.parse(config);
expect(result.movements![0]?.mcp_servers).toBeUndefined();
});
it('should reject invalid mcp_servers (missing command for stdio)', () => {
const config = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
mcp_servers: {
broken: { args: ['--flag'] },
},
instruction: '{task}',
},
],
};
expect(() => PieceConfigRawSchema.parse(config)).toThrow();
});
it('should reject invalid mcp_servers (missing url for sse)', () => {
const config = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
mcp_servers: {
broken: { type: 'sse' },
},
instruction: '{task}',
},
],
};
expect(() => PieceConfigRawSchema.parse(config)).toThrow();
});
});
describe('McpServerConfigSchema', () => {
it('should parse stdio config', () => {
const config = { command: 'npx', args: ['-y', 'some-server'], env: { NODE_ENV: 'test' } };
const result = McpServerConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should parse stdio config with command only', () => {
const config = { command: 'mcp-server' };
const result = McpServerConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should parse stdio config with explicit type', () => {
const config = { type: 'stdio' as const, command: 'npx', args: ['-y', 'some-server'] };
const result = McpServerConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should parse sse config', () => {
const config = { type: 'sse' as const, url: 'http://localhost:8080/sse' };
const result = McpServerConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should parse sse config with headers', () => {
const config = { type: 'sse' as const, url: 'http://example.com', headers: { 'X-Key': 'val' } };
const result = McpServerConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should parse http config', () => {
const config = { type: 'http' as const, url: 'http://localhost:3000/mcp' };
const result = McpServerConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should parse http config with headers', () => {
const config = { type: 'http' as const, url: 'http://example.com', headers: { Authorization: 'Bearer x' } };
const result = McpServerConfigSchema.parse(config);
expect(result).toEqual(config);
});
it('should reject empty command for stdio', () => {
expect(() => McpServerConfigSchema.parse({ command: '' })).toThrow();
});
it('should reject missing url for sse', () => {
expect(() => McpServerConfigSchema.parse({ type: 'sse' })).toThrow();
});
it('should reject missing url for http', () => {
expect(() => McpServerConfigSchema.parse({ type: 'http' })).toThrow();
});
it('should reject empty url for sse', () => {
expect(() => McpServerConfigSchema.parse({ type: 'sse', url: '' })).toThrow();
});
it('should reject unknown type', () => {
expect(() => McpServerConfigSchema.parse({ type: 'websocket', url: 'ws://localhost' })).toThrow();
});
it('should reject empty object', () => {
expect(() => McpServerConfigSchema.parse({})).toThrow();
});
}); });
describe('CustomAgentConfigSchema', () => { describe('CustomAgentConfigSchema', () => {

View File

@ -3,9 +3,10 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { SdkOptionsBuilder } from '../infra/claude/options-builder.js'; import { SdkOptionsBuilder, buildSdkOptions } from '../infra/claude/options-builder.js';
import { mapToCodexSandboxMode } from '../infra/codex/types.js'; import { mapToCodexSandboxMode } from '../infra/codex/types.js';
import type { PermissionMode } from '../core/models/index.js'; import type { PermissionMode } from '../core/models/index.js';
import type { ClaudeSpawnOptions } from '../infra/claude/types.js';
describe('SdkOptionsBuilder.mapToSdkPermissionMode', () => { describe('SdkOptionsBuilder.mapToSdkPermissionMode', () => {
it('should map readonly to SDK default', () => { it('should map readonly to SDK default', () => {
@ -52,3 +53,53 @@ describe('mapToCodexSandboxMode', () => {
} }
}); });
}); });
describe('SdkOptionsBuilder.build() — mcpServers', () => {
it('should include mcpServers in SDK options when provided', () => {
const spawnOptions: ClaudeSpawnOptions = {
cwd: '/tmp/test',
mcpServers: {
playwright: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
},
},
};
const sdkOptions = buildSdkOptions(spawnOptions);
expect(sdkOptions.mcpServers).toEqual({
playwright: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
},
});
});
it('should not include mcpServers in SDK options when not provided', () => {
const spawnOptions: ClaudeSpawnOptions = {
cwd: '/tmp/test',
};
const sdkOptions = buildSdkOptions(spawnOptions);
expect(sdkOptions).not.toHaveProperty('mcpServers');
});
it('should include mcpServers alongside other options', () => {
const spawnOptions: ClaudeSpawnOptions = {
cwd: '/tmp/test',
allowedTools: ['Read', 'mcp__playwright__*'],
mcpServers: {
playwright: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
},
},
permissionMode: 'edit',
};
const sdkOptions = buildSdkOptions(spawnOptions);
expect(sdkOptions.mcpServers).toBeDefined();
expect(sdkOptions.allowedTools).toEqual(['Read', 'mcp__playwright__*']);
expect(sdkOptions.permissionMode).toBe('acceptEdits');
});
});

View File

@ -102,6 +102,7 @@ export class AgentRunner {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools: options.allowedTools ?? agentConfig?.allowedTools, allowedTools: options.allowedTools ?? agentConfig?.allowedTools,
mcpServers: options.mcpServers,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig), model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig),
permissionMode: options.permissionMode, permissionMode: options.permissionMode,

View File

@ -3,7 +3,7 @@
*/ */
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/index.js'; import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/index.js';
import type { PermissionMode, Language } from '../core/models/index.js'; import type { PermissionMode, Language, McpServerConfig } from '../core/models/index.js';
export type { StreamCallback }; export type { StreamCallback };
@ -17,6 +17,8 @@ export interface RunAgentOptions {
personaPath?: string; personaPath?: string;
/** Allowed tools for this agent run */ /** Allowed tools for this agent run */
allowedTools?: string[]; allowedTools?: string[];
/** MCP servers for this agent run */
mcpServers?: Record<string, McpServerConfig>;
/** Maximum number of agentic turns */ /** Maximum number of agentic turns */
maxTurns?: number; maxTurns?: number;
/** Permission mode for tool execution (from piece step) */ /** Permission mode for tool execution (from piece step) */

View File

@ -7,6 +7,7 @@ export type {
OutputContractLabelPath, OutputContractLabelPath,
OutputContractItem, OutputContractItem,
OutputContractEntry, OutputContractEntry,
McpServerConfig,
AgentResponse, AgentResponse,
SessionState, SessionState,
PieceRule, PieceRule,

View File

@ -0,0 +1,40 @@
/**
* Zod schemas for MCP (Model Context Protocol) server configuration.
*
* Supports three transports: stdio, SSE, and HTTP.
* Note: Uses zod v4 syntax for SDK compatibility.
*/
import { z } from 'zod/v4';
/** MCP server configuration for stdio transport */
const McpStdioServerSchema = z.object({
type: z.literal('stdio').optional(),
command: z.string().min(1),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
});
/** MCP server configuration for SSE transport */
const McpSseServerSchema = z.object({
type: z.literal('sse'),
url: z.string().min(1),
headers: z.record(z.string(), z.string()).optional(),
});
/** MCP server configuration for HTTP transport */
const McpHttpServerSchema = z.object({
type: z.literal('http'),
url: z.string().min(1),
headers: z.record(z.string(), z.string()).optional(),
});
/** MCP server configuration (union of all YAML-configurable transports) */
export const McpServerConfigSchema = z.union([
McpStdioServerSchema,
McpSseServerSchema,
McpHttpServerSchema,
]);
/** MCP servers map: server name → config */
export const McpServersSchema = z.record(z.string(), McpServerConfigSchema).optional();

View File

@ -53,6 +53,31 @@ export interface OutputContractItem {
/** Union type for output contract entries */ /** Union type for output contract entries */
export type OutputContractEntry = OutputContractLabelPath | OutputContractItem; export type OutputContractEntry = OutputContractLabelPath | OutputContractItem;
/** MCP server configuration for stdio transport */
export interface McpStdioServerConfig {
type?: 'stdio';
command: string;
args?: string[];
env?: Record<string, string>;
}
/** MCP server configuration for SSE transport */
export interface McpSseServerConfig {
type: 'sse';
url: string;
headers?: Record<string, string>;
}
/** MCP server configuration for HTTP transport */
export interface McpHttpServerConfig {
type: 'http';
url: string;
headers?: Record<string, string>;
}
/** MCP server configuration (union of all YAML-configurable transports) */
export type McpServerConfig = McpStdioServerConfig | McpSseServerConfig | McpHttpServerConfig;
/** Single movement in a piece */ /** Single movement in a piece */
export interface PieceMovement { export interface PieceMovement {
name: string; name: string;
@ -66,6 +91,8 @@ export interface PieceMovement {
personaDisplayName: string; personaDisplayName: string;
/** Allowed tools for this movement (optional, passed to agent execution) */ /** Allowed tools for this movement (optional, passed to agent execution) */
allowedTools?: string[]; allowedTools?: string[];
/** MCP servers configuration for this movement */
mcpServers?: Record<string, McpServerConfig>;
/** Resolved absolute path to persona prompt file (set by loader) */ /** Resolved absolute path to persona prompt file (set by loader) */
personaPath?: string; personaPath?: string;
/** Provider override for this movement */ /** Provider override for this movement */

View File

@ -6,6 +6,9 @@
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { DEFAULT_LANGUAGE } from '../../shared/constants.js'; import { DEFAULT_LANGUAGE } from '../../shared/constants.js';
import { McpServersSchema } from './mcp-schemas.js';
export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js';
/** Agent model schema (opus, sonnet, haiku) */ /** Agent model schema (opus, sonnet, haiku) */
export const AgentModelSchema = z.enum(['opus', 'sonnet', 'haiku']).default('sonnet'); export const AgentModelSchema = z.enum(['opus', 'sonnet', 'haiku']).default('sonnet');
@ -137,6 +140,7 @@ export const ParallelSubMovementRawSchema = z.object({
/** Knowledge reference(s) — key name(s) from piece-level knowledge map */ /** Knowledge reference(s) — key name(s) from piece-level knowledge map */
knowledge: z.union([z.string(), z.array(z.string())]).optional(), knowledge: z.union([z.string(), z.array(z.string())]).optional(),
allowed_tools: z.array(z.string()).optional(), allowed_tools: z.array(z.string()).optional(),
mcp_servers: McpServersSchema,
provider: z.enum(['claude', 'codex', 'mock']).optional(), provider: z.enum(['claude', 'codex', 'mock']).optional(),
model: z.string().optional(), model: z.string().optional(),
permission_mode: PermissionModeSchema.optional(), permission_mode: PermissionModeSchema.optional(),
@ -166,6 +170,7 @@ export const PieceMovementRawSchema = z.object({
/** Knowledge reference(s) — key name(s) from piece-level knowledge map */ /** Knowledge reference(s) — key name(s) from piece-level knowledge map */
knowledge: z.union([z.string(), z.array(z.string())]).optional(), knowledge: z.union([z.string(), z.array(z.string())]).optional(),
allowed_tools: z.array(z.string()).optional(), allowed_tools: z.array(z.string()).optional(),
mcp_servers: McpServersSchema,
provider: z.enum(['claude', 'codex', 'mock']).optional(), provider: z.enum(['claude', 'codex', 'mock']).optional(),
model: z.string().optional(), model: z.string().optional(),
/** Permission mode for tool execution in this movement */ /** Permission mode for tool execution in this movement */

View File

@ -29,6 +29,7 @@ export type {
OutputContractLabelPath, OutputContractLabelPath,
OutputContractItem, OutputContractItem,
OutputContractEntry, OutputContractEntry,
McpServerConfig,
PieceMovement, PieceMovement,
LoopDetectionConfig, LoopDetectionConfig,
LoopMonitorConfig, LoopMonitorConfig,

View File

@ -68,6 +68,7 @@ export class OptionsBuilder {
...this.buildBaseOptions(step), ...this.buildBaseOptions(step),
sessionId: shouldResumeSession ? this.getSessionId(step.persona ?? step.name) : undefined, sessionId: shouldResumeSession ? this.getSessionId(step.persona ?? step.name) : undefined,
allowedTools, allowedTools,
mcpServers: step.mcpServers,
}; };
} }

View File

@ -11,51 +11,10 @@ import { createLogger } from '../../shared/utils/index.js';
import { loadTemplate } from '../../shared/prompts/index.js'; import { loadTemplate } from '../../shared/prompts/index.js';
export type { ClaudeCallOptions } from './types.js'; export type { ClaudeCallOptions } from './types.js';
export { detectRuleIndex, isRegexSafe } from './utils.js';
const log = createLogger('client'); const log = createLogger('client');
/**
* 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;
}
/** Validate regex pattern for ReDoS safety */
export function isRegexSafe(pattern: string): boolean {
if (pattern.length > 200) {
return false;
}
const dangerousPatterns = [
/\(\.\*\)\+/, // (.*)+
/\(\.\+\)\*/, // (.+)*
/\(\.\*\)\*/, // (.*)*
/\(\.\+\)\+/, // (.+)+
/\([^)]*\|[^)]*\)\+/, // (a|b)+
/\([^)]*\|[^)]*\)\*/, // (a|b)*
];
for (const dangerous of dangerousPatterns) {
if (dangerous.test(pattern)) {
return false;
}
}
return true;
}
/** /**
* High-level Claude client for calling Claude with various configurations. * High-level Claude client for calling Claude with various configurations.
* *
@ -81,6 +40,7 @@ export class ClaudeClient {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
mcpServers: options.mcpServers,
model: options.model, model: options.model,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
systemPrompt: options.systemPrompt, systemPrompt: options.systemPrompt,
@ -167,6 +127,7 @@ export class ClaudeClient {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
mcpServers: options.mcpServers,
model: options.model, model: options.model,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
permissionMode: options.permissionMode, permissionMode: options.permissionMode,

View File

@ -63,6 +63,7 @@ export class SdkOptionsBuilder {
if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns; if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns;
if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools; if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools;
if (this.options.agents) sdkOptions.agents = this.options.agents; if (this.options.agents) sdkOptions.agents = this.options.agents;
if (this.options.mcpServers) sdkOptions.mcpServers = this.options.mcpServers;
if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt; if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt;
if (canUseTool) sdkOptions.canUseTool = canUseTool; if (canUseTool) sdkOptions.canUseTool = canUseTool;
if (hooks) sdkOptions.hooks = hooks; if (hooks) sdkOptions.hooks = hooks;

View File

@ -6,7 +6,7 @@
*/ */
import type { PermissionUpdate, AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; import type { PermissionUpdate, AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
import type { PermissionMode } from '../../core/models/index.js'; import type { PermissionMode, McpServerConfig } from '../../core/models/index.js';
import type { PermissionResult } from '../../core/piece/index.js'; import type { PermissionResult } from '../../core/piece/index.js';
// Re-export PermissionResult for convenience // Re-export PermissionResult for convenience
@ -121,6 +121,8 @@ export interface ClaudeCallOptions {
cwd: string; cwd: string;
sessionId?: string; sessionId?: string;
allowedTools?: string[]; allowedTools?: string[];
/** MCP servers configuration */
mcpServers?: Record<string, McpServerConfig>;
model?: string; model?: string;
maxTurns?: number; maxTurns?: number;
systemPrompt?: string; systemPrompt?: string;
@ -145,6 +147,8 @@ export interface ClaudeSpawnOptions {
cwd: string; cwd: string;
sessionId?: string; sessionId?: string;
allowedTools?: string[]; allowedTools?: string[];
/** MCP servers configuration */
mcpServers?: Record<string, McpServerConfig>;
model?: string; model?: string;
maxTurns?: number; maxTurns?: number;
systemPrompt?: string; systemPrompt?: string;

47
src/infra/claude/utils.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* Utility functions for Claude client operations.
*
* Stateless helpers for rule detection and regex safety validation.
*/
/**
* 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;
}
/** Validate regex pattern for ReDoS safety */
export function isRegexSafe(pattern: string): boolean {
if (pattern.length > 200) {
return false;
}
const dangerousPatterns = [
/\(\.\*\)\+/, // (.*)+
/\(\.\+\)\*/, // (.+)*
/\(\.\*\)\*/, // (.*)*
/\(\.\+\)\+/, // (.+)+
/\([^)]*\|[^)]*\)\+/, // (a|b)+
/\([^)]*\|[^)]*\)\*/, // (a|b)*
];
for (const dangerous of dangerousPatterns) {
if (dangerous.test(pattern)) {
return false;
}
}
return true;
}

View File

@ -6,114 +6,23 @@
*/ */
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os'; import { dirname } from 'node:path';
import { join, dirname, basename } from 'node:path';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
import type { z } from 'zod'; import type { z } from 'zod';
import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js';
import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractLabelPath, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js';
import {
type PieceSections,
resolveResourceContent,
resolveRefToContent,
resolveRefList,
resolveSectionMap,
extractPersonaDisplayName,
resolvePersona,
} from './resource-resolver.js';
type RawStep = z.output<typeof PieceMovementRawSchema>; type RawStep = z.output<typeof PieceMovementRawSchema>;
/** Resolve a resource spec to an absolute file path. */
function resolveResourcePath(spec: string, pieceDir: string): string {
if (spec.startsWith('./')) return join(pieceDir, spec.slice(2));
if (spec.startsWith('~')) return join(homedir(), spec.slice(1));
if (spec.startsWith('/')) return spec;
return join(pieceDir, spec);
}
/**
* Resolve a resource spec to its file content.
* If the spec ends with .md and the file exists, returns file content.
* Otherwise returns the spec as-is (treated as inline content).
*/
function resolveResourceContent(spec: string | undefined, pieceDir: string): string | undefined {
if (spec == null) return undefined;
if (spec.endsWith('.md')) {
const resolved = resolveResourcePath(spec, pieceDir);
if (existsSync(resolved)) return readFileSync(resolved, 'utf-8');
}
return spec;
}
/**
* Resolve a section reference to content.
* Looks up ref in resolvedMap first, then falls back to resolveResourceContent.
*/
function resolveRefToContent(
ref: string,
resolvedMap: Record<string, string> | undefined,
pieceDir: string,
): string | undefined {
const mapped = resolvedMap?.[ref];
if (mapped) return mapped;
return resolveResourceContent(ref, pieceDir);
}
/** Resolve multiple references to content strings (for fields that accept string | string[]). */
function resolveRefList(
refs: string | string[] | undefined,
resolvedMap: Record<string, string> | undefined,
pieceDir: string,
): string[] | undefined {
if (refs == null) return undefined;
const list = Array.isArray(refs) ? refs : [refs];
const contents: string[] = [];
for (const ref of list) {
const content = resolveRefToContent(ref, resolvedMap, pieceDir);
if (content) contents.push(content);
}
return contents.length > 0 ? contents : undefined;
}
/** Resolve a piece-level section map (each value resolved to file content or inline). */
function resolveSectionMap(
raw: Record<string, string> | undefined,
pieceDir: string,
): Record<string, string> | undefined {
if (!raw) return undefined;
const resolved: Record<string, string> = {};
for (const [name, value] of Object.entries(raw)) {
const content = resolveResourceContent(value, pieceDir);
if (content) resolved[name] = content;
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
/** Extract display name from persona path (e.g., "coder.md" → "coder"). */
function extractPersonaDisplayName(personaPath: string): string {
return basename(personaPath, '.md');
}
/** Resolve persona from YAML field to spec + absolute path. */
function resolvePersona(
rawPersona: string | undefined,
sections: PieceSections,
pieceDir: string,
): { personaSpec?: string; personaPath?: string } {
if (!rawPersona) return {};
const personaSpec = sections.personas?.[rawPersona] ?? rawPersona;
const resolved = resolveResourcePath(personaSpec, pieceDir);
const personaPath = existsSync(resolved) ? resolved : undefined;
return { personaSpec, personaPath };
}
/** Pre-resolved section maps passed to movement normalization. */
interface PieceSections {
/** Persona name → file path (raw, not content-resolved) */
personas?: Record<string, string>;
/** Policy name → resolved content */
resolvedPolicies?: Record<string, string>;
/** Knowledge name → resolved content */
resolvedKnowledge?: Record<string, string>;
/** Instruction name → resolved content */
resolvedInstructions?: Record<string, string>;
/** Report format name → resolved content */
resolvedReportFormats?: Record<string, string>;
}
/** Check if a raw output contract item is the object form (has 'name' property). */ /** Check if a raw output contract item is the object form (has 'name' property). */
function isOutputContractItem(raw: unknown): raw is { name: string; order?: string; format?: string } { function isOutputContractItem(raw: unknown): raw is { name: string; order?: string; format?: string } {
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw;
@ -271,6 +180,7 @@ function normalizeStepFromRaw(
personaDisplayName: displayName || (personaSpec ? extractPersonaDisplayName(personaSpec) : step.name), personaDisplayName: displayName || (personaSpec ? extractPersonaDisplayName(personaSpec) : step.name),
personaPath, personaPath,
allowedTools: step.allowed_tools, allowedTools: step.allowed_tools,
mcpServers: step.mcp_servers,
provider: step.provider, provider: step.provider,
model: step.model, model: step.model,
permissionMode: step.permission_mode, permissionMode: step.permission_mode,

View File

@ -0,0 +1,109 @@
/**
* Resource resolution helpers for piece YAML parsing.
*
* Resolves file paths, content references, and persona specs
* from piece-level section maps.
*/
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, basename } from 'node:path';
/** Pre-resolved section maps passed to movement normalization. */
export interface PieceSections {
/** Persona name → file path (raw, not content-resolved) */
personas?: Record<string, string>;
/** Policy name → resolved content */
resolvedPolicies?: Record<string, string>;
/** Knowledge name → resolved content */
resolvedKnowledge?: Record<string, string>;
/** Instruction name → resolved content */
resolvedInstructions?: Record<string, string>;
/** Report format name → resolved content */
resolvedReportFormats?: Record<string, string>;
}
/** Resolve a resource spec to an absolute file path. */
export function resolveResourcePath(spec: string, pieceDir: string): string {
if (spec.startsWith('./')) return join(pieceDir, spec.slice(2));
if (spec.startsWith('~')) return join(homedir(), spec.slice(1));
if (spec.startsWith('/')) return spec;
return join(pieceDir, spec);
}
/**
* Resolve a resource spec to its file content.
* If the spec ends with .md and the file exists, returns file content.
* Otherwise returns the spec as-is (treated as inline content).
*/
export function resolveResourceContent(spec: string | undefined, pieceDir: string): string | undefined {
if (spec == null) return undefined;
if (spec.endsWith('.md')) {
const resolved = resolveResourcePath(spec, pieceDir);
if (existsSync(resolved)) return readFileSync(resolved, 'utf-8');
}
return spec;
}
/**
* Resolve a section reference to content.
* Looks up ref in resolvedMap first, then falls back to resolveResourceContent.
*/
export function resolveRefToContent(
ref: string,
resolvedMap: Record<string, string> | undefined,
pieceDir: string,
): string | undefined {
const mapped = resolvedMap?.[ref];
if (mapped) return mapped;
return resolveResourceContent(ref, pieceDir);
}
/** Resolve multiple references to content strings (for fields that accept string | string[]). */
export function resolveRefList(
refs: string | string[] | undefined,
resolvedMap: Record<string, string> | undefined,
pieceDir: string,
): string[] | undefined {
if (refs == null) return undefined;
const list = Array.isArray(refs) ? refs : [refs];
const contents: string[] = [];
for (const ref of list) {
const content = resolveRefToContent(ref, resolvedMap, pieceDir);
if (content) contents.push(content);
}
return contents.length > 0 ? contents : undefined;
}
/** Resolve a piece-level section map (each value resolved to file content or inline). */
export function resolveSectionMap(
raw: Record<string, string> | undefined,
pieceDir: string,
): Record<string, string> | undefined {
if (!raw) return undefined;
const resolved: Record<string, string> = {};
for (const [name, value] of Object.entries(raw)) {
const content = resolveResourceContent(value, pieceDir);
if (content) resolved[name] = content;
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
/** Extract display name from persona path (e.g., "coder.md" → "coder"). */
export function extractPersonaDisplayName(personaPath: string): string {
return basename(personaPath, '.md');
}
/** Resolve persona from YAML field to spec + absolute path. */
export function resolvePersona(
rawPersona: string | undefined,
sections: PieceSections,
pieceDir: string,
): { personaSpec?: string; personaPath?: string } {
if (!rawPersona) return {};
const personaSpec = sections.personas?.[rawPersona] ?? rawPersona;
const resolved = resolveResourcePath(personaSpec, pieceDir);
const personaPath = existsSync(resolved) ? resolved : undefined;
return { personaSpec, personaPath };
}

View File

@ -12,6 +12,7 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
allowedTools: options.allowedTools, allowedTools: options.allowedTools,
mcpServers: options.mcpServers,
model: options.model, model: options.model,
maxTurns: options.maxTurns, maxTurns: options.maxTurns,
permissionMode: options.permissionMode, permissionMode: options.permissionMode,

View File

@ -3,7 +3,7 @@
*/ */
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js'; import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js';
import type { AgentResponse, PermissionMode } from '../../core/models/index.js'; import type { AgentResponse, PermissionMode, McpServerConfig } from '../../core/models/index.js';
/** Agent setup configuration — determines HOW the provider invokes the agent */ /** Agent setup configuration — determines HOW the provider invokes the agent */
export interface AgentSetup { export interface AgentSetup {
@ -23,6 +23,8 @@ export interface ProviderCallOptions {
sessionId?: string; sessionId?: string;
model?: string; model?: string;
allowedTools?: string[]; allowedTools?: string[];
/** MCP servers configuration */
mcpServers?: Record<string, McpServerConfig>;
/** Maximum number of agentic turns */ /** Maximum number of agentic turns */
maxTurns?: number; maxTurns?: number;
/** Permission mode for tool execution (from piece step) */ /** Permission mode for tool execution (from piece step) */