旧仕様削除

This commit is contained in:
nrslib 2026-02-07 09:28:43 +09:00
parent b5e9d1fcbe
commit 1df353148e
5 changed files with 110 additions and 197 deletions

View File

@ -292,7 +292,7 @@ describe('stances', () => {
expect(config.movements[0]!.stanceContents).toBeUndefined(); expect(config.movements[0]!.stanceContents).toBeUndefined();
}); });
it('should leave stanceContents undefined for unknown stance names', () => { it('should treat unknown stance names as inline content', () => {
const raw = { const raw = {
name: 'test-piece', name: 'test-piece',
stances: { stances: {
@ -309,7 +309,7 @@ describe('stances', () => {
}; };
const config = normalizePieceConfig(raw, testDir); const config = normalizePieceConfig(raw, testDir);
expect(config.movements[0]!.stanceContents).toBeUndefined(); expect(config.movements[0]!.stanceContents).toEqual(['nonexistent']);
}); });
it('should resolve stances in parallel sub-movements', () => { it('should resolve stances in parallel sub-movements', () => {

View File

@ -71,7 +71,7 @@ export async function ejectBuiltin(name?: string, options: EjectOptions = {}): P
success(`Ejected piece: ${pieceDest}`); success(`Ejected piece: ${pieceDest}`);
} }
// Copy related resource files (agents, personas, stances, instructions, report-formats) // Copy related resource files (personas, stances, instructions, report-formats)
const resourceRefs = extractResourceRelativePaths(builtinPath); const resourceRefs = extractResourceRelativePaths(builtinPath);
let copiedCount = 0; let copiedCount = 0;

View File

@ -1,10 +1,9 @@
/** /**
* Agent and persona configuration loader * Persona configuration loader
* *
* Loads persona prompts with user builtin fallback: * Loads persona prompts with user builtin fallback:
* 1. User personas: ~/.takt/personas/*.md (preferred) * 1. User personas: ~/.takt/personas/*.md
* 2. User agents (legacy): ~/.takt/agents/*.md (backward compat) * 2. Builtin personas: resources/global/{lang}/personas/*.md
* 3. Builtin personas: resources/global/{lang}/personas/*.md
*/ */
import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { readFileSync, existsSync, readdirSync } from 'node:fs';
@ -12,7 +11,6 @@ import { join, basename } from 'node:path';
import type { CustomAgentConfig } from '../../../core/models/index.js'; import type { CustomAgentConfig } from '../../../core/models/index.js';
import { import {
getGlobalPersonasDir, getGlobalPersonasDir,
getGlobalAgentsDir,
getGlobalPiecesDir, getGlobalPiecesDir,
getBuiltinPersonasDir, getBuiltinPersonasDir,
getBuiltinPiecesDir, getBuiltinPiecesDir,
@ -20,12 +18,11 @@ import {
} from '../paths.js'; } from '../paths.js';
import { getLanguage } from '../global/globalConfig.js'; import { getLanguage } from '../global/globalConfig.js';
/** Get all allowed base directories for persona/agent prompt files */ /** Get all allowed base directories for persona prompt files */
function getAllowedPromptBases(): string[] { function getAllowedPromptBases(): string[] {
const lang = getLanguage(); const lang = getLanguage();
return [ return [
getGlobalPersonasDir(), getGlobalPersonasDir(),
getGlobalAgentsDir(),
getGlobalPiecesDir(), getGlobalPiecesDir(),
getBuiltinPersonasDir(lang), getBuiltinPersonasDir(lang),
getBuiltinPiecesDir(lang), getBuiltinPiecesDir(lang),
@ -51,20 +48,12 @@ export function loadAgentsFromDir(dirPath: string): CustomAgentConfig[] {
return agents; return agents;
} }
/** Load all custom agents from global directories (~/.takt/personas/, ~/.takt/agents/) */ /** Load all custom agents from ~/.takt/personas/ */
export function loadCustomAgents(): Map<string, CustomAgentConfig> { export function loadCustomAgents(): Map<string, CustomAgentConfig> {
const agents = new Map<string, CustomAgentConfig>(); const agents = new Map<string, CustomAgentConfig>();
// Legacy: ~/.takt/agents/*.md (loaded first, overwritten by personas/)
for (const agent of loadAgentsFromDir(getGlobalAgentsDir())) {
agents.set(agent.name, agent);
}
// Preferred: ~/.takt/personas/*.md (takes priority)
for (const agent of loadAgentsFromDir(getGlobalPersonasDir())) { for (const agent of loadAgentsFromDir(getGlobalPersonasDir())) {
agents.set(agent.name, agent); agents.set(agent.name, agent);
} }
return agents; return agents;
} }
@ -73,13 +62,7 @@ export function listCustomAgents(): string[] {
return Array.from(loadCustomAgents().keys()).sort(); return Array.from(loadCustomAgents().keys()).sort();
} }
/** /** Load agent prompt content. */
* Load agent prompt content.
* Prompts can be loaded from:
* - ~/.takt/personas/*.md (preferred)
* - ~/.takt/agents/*.md (legacy)
* - ~/.takt/pieces/{piece}/*.md (piece-specific)
*/
export function loadAgentPrompt(agent: CustomAgentConfig): string { export function loadAgentPrompt(agent: CustomAgentConfig): string {
if (agent.prompt) { if (agent.prompt) {
return agent.prompt; return agent.prompt;
@ -102,10 +85,7 @@ export function loadAgentPrompt(agent: CustomAgentConfig): string {
throw new Error(`Agent ${agent.name} has no prompt defined`); throw new Error(`Agent ${agent.name} has no prompt defined`);
} }
/** /** Load persona prompt from a resolved path. */
* Load persona prompt from a resolved path.
* Used by piece engine when personaPath is already resolved.
*/
export function loadPersonaPromptFromPath(personaPath: string): string { export function loadPersonaPromptFromPath(personaPath: string): string {
const isValid = getAllowedPromptBases().some((base) => isPathSafe(base, personaPath)); const isValid = getAllowedPromptBases().some((base) => isPathSafe(base, personaPath));
if (!isValid) { if (!isValid) {

View File

@ -6,87 +6,110 @@
*/ */
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname, basename } 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, ReportConfig, ReportObjectConfig, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, PieceRule, ReportConfig, ReportObjectConfig, LoopMonitorConfig, LoopMonitorJudge } from '../../../core/models/index.js';
/** Parsed movement type from Zod schema (replaces `any`) */
type RawStep = z.output<typeof PieceMovementRawSchema>; type RawStep = z.output<typeof PieceMovementRawSchema>;
/** /** Resolve a resource spec to an absolute file path. */
* Resolve persona path from piece specification. function resolveResourcePath(spec: string, pieceDir: string): string {
* - Relative path (./persona.md): relative to piece directory if (spec.startsWith('./')) return join(pieceDir, spec.slice(2));
* - Absolute path (/path/to/persona.md or ~/...): use as-is if (spec.startsWith('~')) return join(homedir(), spec.slice(1));
*/ if (spec.startsWith('/')) return spec;
function resolvePersonaPathForPiece(personaSpec: string, pieceDir: string): string { return join(pieceDir, spec);
if (personaSpec.startsWith('./')) {
return join(pieceDir, personaSpec.slice(2));
}
if (personaSpec.startsWith('~')) {
const homedir = process.env.HOME || process.env.USERPROFILE || '';
return join(homedir, personaSpec.slice(1));
}
if (personaSpec.startsWith('/')) {
return personaSpec;
}
return join(pieceDir, personaSpec);
} }
/** /**
* Extract display name from persona path. * Resolve a resource spec to its file content.
* e.g., "~/.takt/personas/coder.md" -> "coder" * 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 { function extractPersonaDisplayName(personaPath: string): string {
return basename(personaPath, '.md'); return basename(personaPath, '.md');
} }
/** /** Resolve persona from YAML field to spec + absolute path. */
* Resolve a string value that may be a file path. function resolvePersona(
* If the value ends with .md and the file exists (resolved relative to pieceDir), rawPersona: string | undefined,
* read and return the file contents. Otherwise return the value as-is. sections: PieceSections,
*/ pieceDir: string,
function resolveContentPath(value: string | undefined, pieceDir: string): string | undefined { ): { personaSpec?: string; personaPath?: string } {
if (value == null) return undefined; if (!rawPersona) return {};
if (value.endsWith('.md')) { const personaSpec = sections.personas?.[rawPersona] ?? rawPersona;
let resolvedPath = value;
if (value.startsWith('./')) { const resolved = resolveResourcePath(personaSpec, pieceDir);
resolvedPath = join(pieceDir, value.slice(2)); const personaPath = existsSync(resolved) ? resolved : undefined;
} else if (value.startsWith('~')) { return { personaSpec, personaPath };
const homedir = process.env.HOME || process.env.USERPROFILE || '';
resolvedPath = join(homedir, value.slice(1));
} else if (!value.startsWith('/')) {
resolvedPath = join(pieceDir, value);
}
if (existsSync(resolvedPath)) {
return readFileSync(resolvedPath, 'utf-8');
}
}
return value;
} }
/** /** Pre-resolved section maps passed to movement normalization. */
* Resolve a value from a section map by key lookup.
* If the value matches a key in sectionMap, return the mapped value.
* Otherwise return the value as-is (treated as file path or inline content).
*/
function resolveSectionReference(
value: string,
sectionMap: Record<string, string> | undefined,
): string {
const resolved = sectionMap?.[value];
return resolved ?? value;
}
/** Section maps parsed from piece YAML for section reference expansion */
interface PieceSections { interface PieceSections {
/** Persona name → file path (raw, not content-resolved) */
personas?: Record<string, string>; personas?: Record<string, string>;
stances?: Record<string, string>; /** Stance name → resolved content */
/** Stances resolved to file content (for backward-compat plain name lookup) */
resolvedStances?: Record<string, string>; resolvedStances?: Record<string, string>;
instructions?: Record<string, string>; /** Instruction name → resolved content */
reportFormats?: Record<string, string>; resolvedInstructions?: Record<string, string>;
/** Report format name → resolved content */
resolvedReportFormats?: Record<string, string>;
} }
/** Check if a raw report value is the object form (has 'name' property). */ /** Check if a raw report value is the object form (has 'name' property). */
@ -94,24 +117,19 @@ function isReportObject(raw: unknown): raw is { name: string; order?: string; fo
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw; return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw;
} }
/** /** Normalize the raw report field from YAML into internal format. */
* Normalize the raw report field from YAML into internal format.
* Supports section references for format/order fields via rawReportFormats section.
*/
function normalizeReport( function normalizeReport(
raw: string | Record<string, string>[] | { name: string; order?: string; format?: string } | undefined, raw: string | Record<string, string>[] | { name: string; order?: string; format?: string } | undefined,
pieceDir: string, pieceDir: string,
rawReportFormats?: Record<string, string>, resolvedReportFormats?: Record<string, string>,
): string | ReportConfig[] | ReportObjectConfig | undefined { ): string | ReportConfig[] | ReportObjectConfig | undefined {
if (raw == null) return undefined; if (raw == null) return undefined;
if (typeof raw === 'string') return raw; if (typeof raw === 'string') return raw;
if (isReportObject(raw)) { if (isReportObject(raw)) {
const expandedFormat = raw.format ? resolveSectionReference(raw.format, rawReportFormats) : undefined;
const expandedOrder = raw.order ? resolveSectionReference(raw.order, rawReportFormats) : undefined;
return { return {
name: raw.name, name: raw.name,
order: resolveContentPath(expandedOrder, pieceDir), order: raw.order ? resolveRefToContent(raw.order, resolvedReportFormats, pieceDir) : undefined,
format: resolveContentPath(expandedFormat, pieceDir), format: raw.format ? resolveRefToContent(raw.format, resolvedReportFormats, pieceDir) : undefined,
}; };
} }
return (raw as Record<string, string>[]).flatMap((entry) => return (raw as Record<string, string>[]).flatMap((entry) =>
@ -197,34 +215,6 @@ function normalizeRule(r: {
}; };
} }
/**
* Resolve stance references for a movement.
*
* Resolution priority:
* 1. Section key look up in resolvedStances (pre-resolved content)
* 2. File path (`./path`, `../path`, `*.md`) resolve file directly
* 3. Unknown names are silently ignored
*/
function resolveStanceContents(
stanceRef: string | string[] | undefined,
sections: PieceSections,
pieceDir: string,
): string[] | undefined {
if (stanceRef == null) return undefined;
const refs = Array.isArray(stanceRef) ? stanceRef : [stanceRef];
const contents: string[] = [];
for (const ref of refs) {
const sectionContent = sections.resolvedStances?.[ref];
if (sectionContent) {
contents.push(sectionContent);
} else if (ref.endsWith('.md') || ref.startsWith('./') || ref.startsWith('../')) {
const content = resolveContentPath(ref, pieceDir);
if (content) contents.push(content);
}
}
return contents.length > 0 ? contents : undefined;
}
/** Normalize a raw step into internal PieceMovement format. */ /** Normalize a raw step into internal PieceMovement format. */
function normalizeStepFromRaw( function normalizeStepFromRaw(
step: RawStep, step: RawStep,
@ -233,31 +223,17 @@ function normalizeStepFromRaw(
): PieceMovement { ): PieceMovement {
const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule); const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule);
// Resolve persona via section reference expansion
const rawPersona = (step as Record<string, unknown>).persona as string | undefined; const rawPersona = (step as Record<string, unknown>).persona as string | undefined;
const expandedPersona = rawPersona ? resolveSectionReference(rawPersona, sections.personas) : undefined; const { personaSpec, personaPath } = resolvePersona(rawPersona, sections, pieceDir);
const personaSpec: string | undefined = expandedPersona || undefined;
// Resolve persona path: if the resolved path exists on disk, use it; otherwise leave personaPath undefined
// so that the runner treats personaSpec as an inline system prompt string.
let personaPath: string | undefined;
if (personaSpec) {
const resolved = resolvePersonaPathForPiece(personaSpec, pieceDir);
if (existsSync(resolved)) {
personaPath = resolved;
}
}
const displayName: string | undefined = (step as Record<string, unknown>).persona_name as string const displayName: string | undefined = (step as Record<string, unknown>).persona_name as string
|| undefined; || undefined;
// Resolve stance references (supports section key, file paths)
const stanceRef = (step as Record<string, unknown>).stance as string | string[] | undefined; const stanceRef = (step as Record<string, unknown>).stance as string | string[] | undefined;
const stanceContents = resolveStanceContents(stanceRef, sections, pieceDir); const stanceContents = resolveRefList(stanceRef, sections.resolvedStances, pieceDir);
// Resolve instruction: instruction_template > instruction (with section reference expansion) > default
const expandedInstruction = step.instruction const expandedInstruction = step.instruction
? resolveContentPath(resolveSectionReference(step.instruction, sections.instructions), pieceDir) ? resolveRefToContent(step.instruction, sections.resolvedInstructions, pieceDir)
: undefined; : undefined;
const result: PieceMovement = { const result: PieceMovement = {
@ -272,9 +248,9 @@ function normalizeStepFromRaw(
model: step.model, model: step.model,
permissionMode: step.permission_mode, permissionMode: step.permission_mode,
edit: step.edit, edit: step.edit,
instructionTemplate: resolveContentPath(step.instruction_template, pieceDir) || expandedInstruction || '{task}', instructionTemplate: resolveResourceContent(step.instruction_template, pieceDir) || expandedInstruction || '{task}',
rules, rules,
report: normalizeReport(step.report, pieceDir, sections.reportFormats), report: normalizeReport(step.report, pieceDir, sections.resolvedReportFormats),
passPreviousResponse: step.pass_previous_response ?? true, passPreviousResponse: step.pass_previous_response ?? true,
stanceContents, stanceContents,
}; };
@ -286,31 +262,18 @@ function normalizeStepFromRaw(
return result; return result;
} }
/** /** Normalize a raw loop monitor judge from YAML into internal format. */
* Normalize a raw loop monitor judge from YAML into internal format.
* Resolves persona paths and instruction_template content paths.
*/
function normalizeLoopMonitorJudge( function normalizeLoopMonitorJudge(
raw: { persona?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> }, raw: { persona?: string; instruction_template?: string; rules: Array<{ condition: string; next: string }> },
pieceDir: string, pieceDir: string,
sections: PieceSections, sections: PieceSections,
): LoopMonitorJudge { ): LoopMonitorJudge {
const rawPersona = raw.persona || undefined; const { personaSpec, personaPath } = resolvePersona(raw.persona, sections, pieceDir);
const expandedPersona = rawPersona ? resolveSectionReference(rawPersona, sections.personas) : undefined;
const personaSpec = expandedPersona || undefined;
let personaPath: string | undefined;
if (personaSpec) {
const resolved = resolvePersonaPathForPiece(personaSpec, pieceDir);
if (existsSync(resolved)) {
personaPath = resolved;
}
}
return { return {
persona: personaSpec, persona: personaSpec,
personaPath, personaPath,
instructionTemplate: resolveContentPath(raw.instruction_template, pieceDir), instructionTemplate: resolveResourceContent(raw.instruction_template, pieceDir),
rules: raw.rules.map((r) => ({ condition: r.condition, next: r.next })), rules: raw.rules.map((r) => ({ condition: r.condition, next: r.next })),
}; };
} }
@ -331,52 +294,27 @@ function normalizeLoopMonitors(
})); }));
} }
/** /** Convert raw YAML piece config to internal format. */
* Resolve a piece-level section map.
* Each value is resolved via resolveContentPath (supports .md file references).
* Used for stances, instructions, and report_formats.
*/
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 = resolveContentPath(value, pieceDir);
if (content) {
resolved[name] = content;
}
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
/**
* Convert raw YAML piece config to internal format.
* Agent paths are resolved relative to the piece directory.
*/
export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfig { export function normalizePieceConfig(raw: unknown, pieceDir: string): PieceConfig {
const parsed = PieceConfigRawSchema.parse(raw); const parsed = PieceConfigRawSchema.parse(raw);
// Resolve piece-level section maps
const resolvedStances = resolveSectionMap(parsed.stances, pieceDir); const resolvedStances = resolveSectionMap(parsed.stances, pieceDir);
const resolvedInstructions = resolveSectionMap(parsed.instructions, pieceDir); const resolvedInstructions = resolveSectionMap(parsed.instructions, pieceDir);
const resolvedReportFormats = resolveSectionMap(parsed.report_formats, pieceDir); const resolvedReportFormats = resolveSectionMap(parsed.report_formats, pieceDir);
// Build sections for section reference expansion in movements
const sections: PieceSections = { const sections: PieceSections = {
personas: parsed.personas, personas: parsed.personas,
stances: parsed.stances,
resolvedStances, resolvedStances,
instructions: parsed.instructions, resolvedInstructions,
reportFormats: parsed.report_formats, resolvedReportFormats,
}; };
const movements: PieceMovement[] = parsed.movements.map((step) => const movements: PieceMovement[] = parsed.movements.map((step) =>
normalizeStepFromRaw(step, pieceDir, sections), normalizeStepFromRaw(step, pieceDir, sections),
); );
const initialMovement = parsed.initial_movement ?? movements[0]?.name ?? ''; // Schema guarantees movements.min(1)
const initialMovement = parsed.initial_movement ?? movements[0]!.name;
return { return {
name: parsed.name, name: parsed.name,

View File

@ -21,11 +21,6 @@ export function getGlobalPersonasDir(): string {
return join(getGlobalConfigDir(), 'personas'); return join(getGlobalConfigDir(), 'personas');
} }
/** @deprecated Use getGlobalPersonasDir(). Kept for backward compat with ~/.takt/agents/ */
export function getGlobalAgentsDir(): string {
return join(getGlobalConfigDir(), 'agents');
}
/** Get takt global pieces directory (~/.takt/pieces) */ /** Get takt global pieces directory (~/.takt/pieces) */
export function getGlobalPiecesDir(): string { export function getGlobalPiecesDir(): string {
return join(getGlobalConfigDir(), 'pieces'); return join(getGlobalConfigDir(), 'pieces');