import { readFileSync, existsSync, readdirSync, appendFileSync, mkdirSync, cpSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execFileSync } from 'child_process';
import { randomUUID } from 'node:crypto';
import { parse as parseYaml } from 'yaml';
import { OpenAICompatClient } from '../llm/openai-compat.js';
import { executeMovement, Movement, MovementResult, AgentLoopCallbacks } from './agent-loop.js';
import { Conversation } from './context/conversation.js';
import { ContextManager, type ContextAction } from './context-manager.js';
import { ToolResultCache } from './context/tool-result-cache.js';
import { writeAtomicJson } from './context/atomic-json.js';
import { createFileEventLogger, NoopEventLogger, type EventLogger } from '../progress/event-log.js';
import { ToolContext, ToolsConfig } from './tools/index.js';
import type { SearchFilterConfig } from '../config.js';
import { loadConfig } from '../config.js';
import { logger } from '../logger.js';
import { flushAndStageRecording } from '../user-folder/recording-flush.js';
import { isCancellationAbortCode } from './cancellation.js';

export interface PieceDef {
  name: string;
  description: string;
  max_movements: number;
  initial_movement: string;
  triggers?: {
    keywords: string[];
  };
  required_mcp?: string[];
  model?: string;  // optional: preferred model for this piece
  // Tool / edit / SSH-connection availability is NOT declared per piece or
  // movement any more — it is resolved entirely from the workspace tool policy
  // (settings → Tools / SSH). See resolveWorkspaceTools + worker.ts. Pieces
  // only describe the movement flow (persona / instruction / rules).
  movements: Array<{
    name: string;
    persona: string;
    instruction: string;
    allowed_commands?: string[];
    rules: Array<{ condition: string; next: string }>;
    default_next?: string;
    max_consecutive_revisits?: number;
  }>;
}

export interface PieceRunResult {
  status: 'completed' | 'aborted' | 'error' | 'waiting_human' | 'waiting_subtasks' | 'cancelled';
  finalOutput: string;
  movementHistory: Array<{ name: string; result: MovementResult }>;
  resumeMovement?: string | null;  // ASK / WAIT_SUBTASKS 時の再開 movement 名
  abortReason?: string | null;
  contextActions: ContextAction[];
  waitReason?: string | null;        // waiting_human の場合の待機理由（例: 'browser_login'）
  browserSessionId?: string | null;  // InteractiveBrowse セッションID
  /**
   * Cancel-traceability PR1: workspace-relative path to the
   * `logs/memory-snapshot-{status}-{runId}-{ts}.json` written on terminal
   * non-success (cancelled/error/aborted/waiting_human). null when the run
   * succeeded or the snapshot write failed.
   */
  memorySnapshotPath?: string | null;
}

export interface PieceRunCallbacks extends AgentLoopCallbacks {
  onMovementStart?: (movementName: string) => void;
  onPieceComplete?: (result: PieceRunResult) => void;
}

const REVIEW_FEEDBACK_MOVEMENTS = new Set(['verify']);
const REVIEW_FEEDBACK_TARGET_MOVEMENTS = new Set(['execute', 'process', 'plan', 'analyze']);
const MAX_FEEDBACK_LENGTH = 1_200;
const MAX_COMBINED_FEEDBACK_LENGTH = 4_000;
const MAX_GIT_STATUS_LENGTH = 1_500;
const MAX_GIT_DIFF_LENGTH = 2_500;

// Phase 6b: terminal next values (COMPLETE/ABORT/ASK) are no longer allowed
// in `rules[].next`. Pieces must use the `complete` tool for terminal moves.
// `default_next` is unaffected — it's an engine-internal sentinel.
const RESERVED_TERMINAL_NEXT_VALUES_PIECE_RUNNER: ReadonlySet<string> = new Set(['COMPLETE', 'ABORT', 'ASK']);

const VALID_MCP_SLUG = /^[a-z0-9_-]{1,64}$/;

export function normalizeRequiredMcp(piece: PieceDef, pieceName: string): void {
  if (piece.required_mcp === undefined) return;
  if (!Array.isArray(piece.required_mcp)) {
    logger.warn(`[piece-runner] loadPiece piece=${pieceName} required_mcp is not an array, ignoring`);
    piece.required_mcp = [];
    return;
  }
  const valid: string[] = [];
  const rejected: unknown[] = [];
  for (const v of piece.required_mcp) {
    if (typeof v === 'string' && VALID_MCP_SLUG.test(v)) {
      valid.push(v);
    } else {
      rejected.push(v);
    }
  }
  if (rejected.length > 0) {
    logger.warn(`[piece-runner] loadPiece piece=${pieceName} dropped invalid required_mcp entries: ${JSON.stringify(rejected)}`);
  }
  piece.required_mcp = valid;
}

/**
 * Union one or more tool-name lists, preserving first-seen order and dropping
 * non-string / empty / duplicate entries. Used to union the resolved
 * workspace-policy tool set with per-task inline grants (`grantedTools`).
 */
export function mergeToolNames(...lists: Array<readonly unknown[] | undefined>): string[] {
  const seen = new Set<string>();
  const out: string[] = [];
  for (const list of lists) {
    if (!Array.isArray(list)) continue;
    for (const v of list) {
      // Drop non-string / empty / whitespace-only entries.
      if (typeof v === 'string' && v.trim().length > 0 && !seen.has(v)) {
        seen.add(v);
        out.push(v);
      }
    }
  }
  return out;
}

/**
 * Validate a parsed piece definition. Throws if any movement's `rules[].next`
 * uses a reserved terminal value. Used by both loadPiece (file-backed pieces)
 * and the CreatePiece tool (runtime-created pieces) so the rule applies
 * uniformly regardless of source.
 */
export function validatePieceDef(piece: PieceDef): void {
  if (!Array.isArray(piece.movements)) return;
  const offenders: string[] = [];
  for (const movement of piece.movements) {
    const rules = Array.isArray(movement?.rules) ? movement.rules : [];
    for (const rule of rules) {
      if (rule && typeof rule.next === 'string' && RESERVED_TERMINAL_NEXT_VALUES_PIECE_RUNNER.has(rule.next)) {
        offenders.push(`movement="${movement.name}" rule.next="${rule.next}" (condition="${rule.condition ?? ''}")`);
      }
    }
  }
  if (offenders.length > 0) {
    throw new Error(
      `Piece "${piece.name}" uses reserved terminal next values in rules[].next:\n  ${offenders.join('\n  ')}\n\n` +
      `Use the \`complete\` tool for terminal moves (status: success | aborted | needs_user_input). ` +
      `\`default_next: COMPLETE\` is still allowed as an engine-internal fallback.`,
    );
  }
}

// pieces/ ディレクトリから piece 定義を読み込む（customPiecesDir を優先探索）
// customPiecesDir は string | string[] を受け付ける（配列の場合は先頭から順に探索）
export function loadPiece(pieceName: string, piecesDir: string = 'pieces', customPiecesDir?: string | string[]): PieceDef {
  let raw: string;
  let source: string;
  const customDirs = customPiecesDir == null ? [] : (Array.isArray(customPiecesDir) ? customPiecesDir : [customPiecesDir]);
  const found = customDirs
    .map((d) => join(d, `${pieceName}.yaml`))
    .find((p) => existsSync(p));
  if (found) {
    logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=custom path=${found}`);
    raw = readFileSync(found, 'utf-8');
    source = 'custom';
  } else {
    const filePath = join(piecesDir, `${pieceName}.yaml`);
    if (!existsSync(filePath)) {
      logger.warn(`[piece-runner] loadPiece piece=${pieceName} not found dirs=[${[...customDirs, piecesDir].join(', ')}]`);
      throw new Error(`Piece not found: ${pieceName}`);
    }
    logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=builtin path=${filePath}`);
    raw = readFileSync(filePath, 'utf-8');
    source = 'builtin';
  }
  const piece = parseYaml(raw) as PieceDef;
  normalizeRequiredMcp(piece, pieceName);
  try {
    validatePieceDef(piece);
  } catch (e) {
    logger.warn(`[piece-runner] loadPiece piece=${pieceName} source=${source} validation failed: ${(e as Error).message}`);
    throw e;
  }
  return piece;
}

/**
 * pieces/ ディレクトリ内の全 Piece から triggers を読み込む（customPiecesDir があれば優先、同名は custom が勝つ）
 */
export function loadAllPieceTriggers(piecesDir: string = 'pieces', customPiecesDir?: string | string[]): Array<{ name: string; keywords: string[] }> {
  const triggers: Array<{ name: string; keywords: string[] }> = [];
  const seen = new Set<string>();

  const customDirsArr = Array.isArray(customPiecesDir) ? customPiecesDir : (customPiecesDir ? [customPiecesDir] : []);
  const dirs = [...customDirsArr, piecesDir];
  logger.info(`[piece-runner] loadAllPieceTriggers scanning dirs=[${dirs.join(', ')}]`);
  for (const dir of dirs) {
    if (!existsSync(dir)) {
      logger.warn(`[piece-runner] loadAllPieceTriggers dir not found: ${dir}`);
      continue;
    }
    const files = readdirSync(dir).filter(f => f.endsWith('.yaml'));
    for (const file of files) {
      const name = file.replace('.yaml', '');
      if (seen.has(name)) continue;
      seen.add(name);
      try {
        const piece = loadPiece(name, piecesDir, customPiecesDir);
        if (piece.triggers?.keywords && piece.triggers.keywords.length > 0) {
          triggers.push({ name: piece.name, keywords: piece.triggers.keywords });
        }
      } catch (e) {
        logger.warn(`Failed to load piece triggers from ${file}: ${e}`);
      }
    }
  }
  logger.info(`[piece-runner] loadAllPieceTriggers loaded ${triggers.length} pieces: [${triggers.map(t => t.name).join(', ')}]`);
  return triggers;
}

// ワークスペース・アプリのアーキタイプテンプレート（docs/examples/workspace-apps/）の
// 在処。エージェントのファイルツールはジョブのワークスペースに jail されているため、
// orchestrator リポジトリ内のこのパスには到達できない。docs.ts と同じく import.meta.url
// からリポジトリルートを解決する（piece-runner は src|dist/engine 配下なので 2 段上）。
const WORKSPACE_APP_PIECE_NAME = 'workspace-app';
const WORKSPACE_APP_TEMPLATES_SRC = resolve(
  dirname(fileURLToPath(import.meta.url)),
  '..',
  '..',
  'docs',
  'examples',
  'workspace-apps',
);

/**
 * workspace-app ピース実行時に、アーキタイプテンプレートをジョブのワークスペース内
 * （readonly/app-templates/）へコピーする。これにより piece / doc の「テンプレを
 * 土台にする」指示が成立し、テンプレ内蔵の正しい postMessage ブリッジ（call ヘルパー）
 * をエージェントがそのまま流用できる。
 *
 * - 冪等: 毎回上書きコピー（テンプレ更新も反映）。
 * - best-effort: 失敗してもジョブは落とさない（エージェントはゼロから書ける）。
 * - readonly/ 配下に置くので assertWritable によりエージェントの誤上書きから保護される。
 */
export function seedWorkspaceAppTemplates(workspacePath: string): void {
  try {
    if (!existsSync(WORKSPACE_APP_TEMPLATES_SRC)) {
      logger.warn(
        `[piece-runner] workspace-app templates source missing src=${WORKSPACE_APP_TEMPLATES_SRC} (skip seed)`,
      );
      return;
    }
    const dest = join(workspacePath, 'readonly', 'app-templates');
    mkdirSync(dest, { recursive: true });
    cpSync(WORKSPACE_APP_TEMPLATES_SRC, dest, { recursive: true });
    logger.info(`[piece-runner] seeded workspace-app templates dest=${dest}`);
  } catch (err) {
    logger.warn(`[piece-runner] failed to seed workspace-app templates: ${err}`);
  }
}

// Piece を実行
export async function runPiece(
  piece: PieceDef,
  taskInstruction: string,
  client: OpenAICompatClient,
  workspacePath: string,
  callbacks?: PieceRunCallbacks,
  toolsConfig?: ToolsConfig,
  options?: {
    resumeMovement?: string;
    askCount?: number;
    maxAskPerJob?: number;
    spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
    hasPendingSubtasks?: () => Promise<boolean>;
    cancelCheck?: () => boolean;
    abortController?: AbortController;
    safetyConfig?: { maxIterations?: number; maxRevisits?: number; bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off'; bashAllowNetwork?: boolean };
    searchFilter?: SearchFilterConfig;
    customPiecesDir?: string | string[];
    contextManager?: ContextManager;
    vlmEnabled?: boolean;
    /** Phase 5: parent's job id, used to populate MemoryHandoff.parentJobId
     * when this run spawns subtasks. Optional — when absent, subtask
     * handoff is skipped (e.g. unit tests). */
    jobId?: string;
    /** Phase 5 PR2: when this run IS a subtask, the parent's job id.
     * Combined with `childWorkspaceRelative`, controls whether delta
     * write is emitted on piece completion. */
    parentJobId?: string;
    /** Phase 5 PR2: this subtask's path relative to the parent workspace,
     * e.g. "subtasks/3". Required to populate
     * `SubtaskResultMemoryDelta.childWorkspaceRelative`. */
    childWorkspaceRelative?: string;
    /** Mission Brief IO. The worker layer constructs this from
     * `localTaskId + repo`. When unset (e.g. unit tests, subtasks), the
     * MissionUpdate tool degrades to a no-op and the system prompt
     * MISSION block is skipped. */
    missionBrief?: import('./tools/core.js').MissionBriefIO;
    /** Conversation recall IO (comments + transcript for THIS task). Bound by
     * the worker layer; unset for subtasks / gitea-issue runs. */
    taskConversation?: import('./tools/core.js').TaskConversationIO;
    /** Workspace-wide task search IO. Bound by the worker layer; unset for
     * subtasks / gitea-issue runs. */
    workspaceTaskSearch?: import('./tools/core.js').WorkspaceTaskSearchIO;
    /** File-provenance read IO (workspace-bound). Bound by the worker layer. */
    fileProvenance?: import('./tools/core.js').FileProvenanceIO;
    /** File-provenance write recorder. Bound to task/job/space by the worker. */
    recordFileProvenance?: (ev: import('./tools/core.js').FileProvenanceEvent & {
      pieceName: string;
      movementName: string;
    }) => void;
    /** Browser session keying: the local task ID this run is bound to (or
     * the root local task if this is a subtask). String form because the
     * SessionManager keys are strings. Undefined for runs not bound to a
     * local task (legacy gitea issue jobs). */
    taskId?: string;
    /** Task owner user.id, threaded into ToolContext so BrowseWeb's noVNC
     * session is auth'd against the right user. */
    userId?: string;
    /** Decrypted Playwright storageState for the browser session profile
     * bound to this job (worker decrypts before runPiece is called). */
    browserSessionState?: object;
    /** Profile id for audit / expiry callbacks. */
    browserSessionProfileId?: number;
    /** Profile metadata used by BrowseWeb's expiry check. */
    browserSessionProfile?: { loggedInSelector: string | null; loginUrlPatterns: string[] };
    /** Worker-provided callback invoked when BrowseWeb detects auth expiry. */
    onAuthExpired?: (profileId: number, reason: string) => void;
    /** Phase 5 (MCP): job owner user.id. Threaded into ToolContext.ownerId. */
    ownerId?: string | null;
    /** Phase 5 (MCP): MCP runtime config. Already-merged with defaults. */
    mcpConfig?: import('./tools/core.js').ToolContext['mcpConfig'];
    /** Piece handoff: when this run is itself a continuation of an earlier
     * job in the same local_task, agent-loop injects a "this is a continuation
     * of piece X" block into the system prompt with the previous job's final
     * result. Worker layer constructs this from `job.continuedFromJobId`. */
    handoffContext?: import('./agent-loop.js').HandoffContext;
    /** Role of the job owner. Gates admin-only tool actions. Defaults to 'user'. */
    userRole?: 'admin' | 'user';
    skillCatalog?: import('./skills.js').SkillCatalog;
    /** Per-task option: when true, MCP tools are not loaded/dispatched. */
    mcpDisabled?: boolean;
    /** Per-task option: when true, skill index is not injected into the system prompt. */
    skillsDisabled?: boolean;
    /** Interjection callback: called between agent-loop iterations to check
     * for user messages sent while the job is running. Only wired for local
     * tasks (not subtasks). */
    checkInterjections?: (movementName: string) => Promise<Array<{ id: number; body: string }>>;
    /** Spaces foundation: resolved space folder for this run. Threaded into
     * ToolContext.folderContext so AGENTS.md / memory / custom-piece resolution
     * targets the space folder. Absent => legacy personal-folder resolution
     * (backward compatible). */
    folderContext?: { rootDir: string; leafId: string };
    /** Spaces foundation: space id this run belongs to. Threaded into
     * ToolContext.spaceId for future per-space MCP/SSH resolution. Phase 1 is a
     * no-op (nobody reads it yet); undefined => global/legacy. */
    spaceId?: string;
    /** Per-space Python package overlay `current` symlink path. Threaded into
     * ToolContext.pythonPackagesDir. undefined => feature disabled / no packages. */
    pythonPackagesDir?: string;
    /** Spaces foundation (plan 3): enable conflict detection for persistent
     * (shared) workspaces. Threaded into ToolContext.conflictDetection.
     * Worker sets this true only for persistent local-task workspaces.
     * Undefined => OFF (legacy byte-identical Read/Write/Edit). */
    conflictDetection?: boolean;
    /** 計画5: 実行ログ root（spec §10）。指定時は activity.log / events.jsonl /
     * lessons / checklist / memory-snapshot をここに書く。
     * 未指定時は workspacePath/logs にフォールバック（後方互換）。Phase A では
     * 常に undefined なので現行と byte 一致。 */
    runtimeDir?: string;
    /** 計画5 (spec §10.3): 競合台帳 dir。スペースタスクは space/{id}/.conflict、
     * それ以外は ToolContext 側で workspacePath/.conflict にフォールバック。 */
    conflictDir?: string;
    /** Tool-request mechanism: per-task grant overlay (tool names a user
     * approved inline), unioned into every movement's effective allowed set. */
    grantedTools?: string[];
    /** Workspace tool policy (A3): pre-resolved set of allowed tools and the
     * edit flag for this job's space — the ONLY source of tool/edit availability.
     * When absent (unit tests), the effective set is just grantedTools and edit
     * defaults to false. Resolved once per job by the worker. */
    workspaceTools?: { allowedTools: string[]; editAllowed: boolean };
    /** SSH workspace scoping: connection IDs registered to this job's space,
     * resolved by the worker when 'ssh' is enabled in the workspace tool policy.
     * The ONLY SSH connection gate. undefined/absent → treated as empty (no
     * connections → SSH tools cleanly reject).
     * Empty array = ssh enabled but no connections registered → clean rejection. */
    workspaceSshConnections?: string[];
    /** Tool-request mechanism: true when this run can pause for inline approval
     * (a reachable user — local task, not a subtask/headless run). */
    toolApprovalInteractive?: boolean;
    /** Tool-request mechanism: record an agent tool request / blocked call.
     * Bound to task/job/space by the worker; undefined => recording disabled. */
    recordToolRequest?: (req: {
      pieceName: string;
      movementName: string;
      toolName: string;
      reason?: string | null;
      category: 'requested' | 'blocked' | 'unknown';
      status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
    }) => void;
    /** Package-request mechanism: record an agent Python package request.
     * Bound to task/job/space by the worker only when the feature is enabled
     * AND a space is resolvable; undefined => feature unavailable this run. */
    recordPackageRequest?: (req: {
      pieceName?: string | null;
      movementName?: string | null;
      spec: string;
      normalizedName: string;
      reason?: string | null;
      status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
    }) => void;
    /** Package-request mechanism: packages already installed in the space
     * overlay (name→spec, version-aware). undefined => feature off. */
    installedPackages?: Array<{ name: string; spec: string }>;
    /** Package-request mechanism: names declined for this job (deny loop guard). */
    declinedPackages?: string[];
  },
): Promise<PieceRunResult> {
  const movementHistory: Array<{ name: string; result: MovementResult }> = [];
  const contextActions: ContextAction[] = [];
  // workspace-app ピースのみ、アーキタイプテンプレートをワークスペースへ seed する。
  // 他ピースのワークスペースは汚さない（Files タブにテンプレ群が出ない）。
  if (piece.name === WORKSPACE_APP_PIECE_NAME && workspacePath) {
    seedWorkspaceAppTemplates(workspacePath);
  }
  let currentMovementName = options?.resumeMovement ?? piece.initial_movement;
  let totalSteps = 0;
  let finalOutput = '';
  // Defensive: a piece missing max_movements (or with a non-positive value)
  // would otherwise make `while (totalSteps < undefined)` evaluate false on
  // the first iteration, exiting before any movement runs and reporting
  // "Exceeded max movements (undefined)". Default to 50 and warn so the
  // root cause (broken piece YAML / runtime CreatePiece without the field)
  // is visible in logs.
  const DEFAULT_MAX_MOVEMENTS = 50;
  const declaredMaxMovements = piece.max_movements;
  const maxMovements = (typeof declaredMaxMovements === 'number' && declaredMaxMovements > 0)
    ? declaredMaxMovements
    : DEFAULT_MAX_MOVEMENTS;
  if (maxMovements !== declaredMaxMovements) {
    logger.warn(`[piece-runner] piece="${piece.name}" missing/invalid max_movements (got ${declaredMaxMovements}), defaulting to ${DEFAULT_MAX_MOVEMENTS}`);
  }
  const totalVisits: Record<string, number> = {};
  const consecutiveVisits: Record<string, number> = {};
  const defaultMaxConsecutive = options?.safetyConfig?.maxRevisits ?? 4;
  const lessonsAccumulator: Array<{ movement: string; lessons: string }> = [];
  // Lives for the whole piece run: every movement shares one cache so a
  // Read in `investigate` can be reused by `plan` / `execute` without
  // rerunning the underlying tool. Phase 1 only covers Read.
  const toolResultCache = new ToolResultCache();

  // Traceability T-1: per-run event logger. Every memory mutation, cache
  // event, watchdog fire, transition, complete and tool dispatch in this
  // run lands in `<workspace>/logs/events.jsonl`.
  const runId = randomUUID();
  // 実行ログ root。options.runtimeDir が指定された場合はそれを使用し、
  // 未指定時は workspacePath/logs にフォールバック（後方互換）。
  const logsRoot = options?.runtimeDir ?? join(workspacePath, 'logs');
  // Phase A: 1ジョブ=1本の連続スレッド。全 movement で共有し transcript に永続する。
  const transcriptPath = logsRoot ? join(logsRoot, 'transcript.jsonl') : undefined;
  const conversation = new Conversation(transcriptPath);
  // P2: 継続ジョブ（handoffContext あり）かつ既存 transcript があれば、過去の会話
  // ターンを再生用にロードする。初回 / 継続でない / transcript 不在では空配列＝
  // 従来の seed（最新コメント1件の handoff のみ）に完全一致（後方互換）。
  const priorTurns = options?.handoffContext && transcriptPath
    ? Conversation.replayableTurns(Conversation.loadFrom(transcriptPath))
    : [];
  if (priorTurns.length > 0) {
    logger.info(`[piece-runner] piece=${piece.name} continuation replay turns=${priorTurns.length}`);
  }
  const eventLogger: EventLogger = workspacePath
    ? createFileEventLogger({ workspacePath, runId, logsRoot: options?.runtimeDir })
    : new NoopEventLogger();
  eventLogger.emit('run_start', {
    pieceName: piece.name,
    parentJobId: options?.parentJobId,
    childWorkspaceRelative: options?.childWorkspaceRelative,
    resumeMovement: options?.resumeMovement,
    initialMovement: piece.initial_movement,
  });

  try {
  while (totalSteps < maxMovements) {
    const guard = enforceLoopGuards(
      piece,
      currentMovementName,
      finalOutput,
      totalVisits,
      consecutiveVisits,
      defaultMaxConsecutive,
      movementHistory,
      contextActions,
      options,
    );
    if (guard.kind === 'abort') {
      const snapshotMeta = writeMemorySnapshotIfNeeded(
        guard.result,
        workspacePath,
        runId,
        currentMovementName,
        lessonsAccumulator,
        totalSteps,
        eventLogger,
        options?.runtimeDir,
      );
      if (snapshotMeta) guard.result.memorySnapshotPath = snapshotMeta.pathRelative;
      // Cancel before any movement step actually started — guardLoopAndCancel
      // catches the user's cancel request at the top of the loop.
      const cancelPhase = guard.result.status === 'cancelled' ? 'before_movement' : null;
      eventLogger.emit(
        'run_complete',
        buildRunCompletePayload(guard.result, snapshotMeta, cancelPhase, currentMovementName),
      );
      callbacks?.onPieceComplete?.(guard.result);
      return guard.result;
    }
    const { movementDef, consecutive, maxConsecutive } = guard;

    callbacks?.onMovementStart?.(currentMovementName);
    logger.info(`[piece-runner] piece=${piece.name} movement=${currentMovementName} step=${totalSteps + 1}/${maxMovements}`);

    const { movement, ctx, enrichedInstruction, callbackBridge } = prepareMovementContext(
      piece,
      movementDef,
      taskInstruction,
      workspacePath,
      toolsConfig,
      movementHistory,
      lessonsAccumulator,
      contextActions,
      callbacks,
      // Traceability T-1: thread the event logger through so every tool
      // dispatch lands in events.jsonl.
      options
        ? { ...options, eventLogger }
        : { eventLogger },
    );

    // Note: the old "skip a SpawnSubTask-requiring movement at the depth limit"
    // optimization was removed with per-piece tool declarations. Tool
    // availability is now a job-wide workspace-policy value, so there is no
    // per-movement signal for "this movement requires SpawnSubTask" to key the
    // skip on. When SpawnSubTask is unavailable the movement simply runs without
    // it (the tool is absent from the effective set); the depth-limit hint below
    // still tells the agent to use delegate instead.

    const result = await executeMovement(movement, enrichedInstruction, client, ctx, {
      callbacks: callbackBridge,
      maxIterations: options?.safetyConfig?.maxIterations,
      contextManager: options?.contextManager,
      cancelSignal: options?.abortController?.signal,
      cancelCheck: options?.cancelCheck,
      visitCount: consecutive,
      maxVisits: movementDef.max_consecutive_revisits ?? defaultMaxConsecutive,
      safetyConfig: options?.safetyConfig,
      toolResultCache,
      handoffContext: options?.handoffContext,
      checkInterjections: options?.checkInterjections,
      conversation,
      priorTurns,
    });

    if (
      REVIEW_FEEDBACK_MOVEMENTS.has(currentMovementName) &&
      result.next &&
      !['COMPLETE', 'ABORT', 'ASK'].includes(result.next)
    ) {
      const nextMovementDef = piece.movements.find((candidate) => candidate.name === result.next);
      if (shouldCarryReviewFeedback(nextMovementDef?.name)) {
        result.output = appendWorkspaceChangeSummary(result.output, workspacePath);
      }
    }

    movementHistory.push({ name: currentMovementName, result });
    totalSteps++;
    finalOutput = result.output;

    // lessons を蓄積 + ファイルに記録
    // LLM が lessons を返さなかった場合でも、summary（result.output）をフォールバックとして記録
    const lessonText = result.lessons || (result.next === 'COMPLETE' ? result.output : null);
    if (lessonText) {
      lessonsAccumulator.push({ movement: currentMovementName, lessons: lessonText });
      writeLessonLog(logsRoot, piece.name, currentMovementName, lessonText);
    }

    // 遷移判定
    const mapped = mapMovementResult(result, {
      piece,
      currentMovementName,
      movementDef,
      askCount: options?.askCount ?? 0,
      maxAskPerJob: options?.maxAskPerJob ?? 2,
      finalOutput,
      movementHistory,
      contextActions,
    });
    if (mapped.kind === 'done') {
      const snapshotMeta = writeMemorySnapshotIfNeeded(
        mapped.result,
        workspacePath,
        runId,
        currentMovementName,
        lessonsAccumulator,
        totalSteps,
        eventLogger,
        options?.runtimeDir,
      );
      if (snapshotMeta) mapped.result.memorySnapshotPath = snapshotMeta.pathRelative;
      // mid-movement cancel reaches here via mapMovementResult → ABORT branch.
      const cancelPhase = mapped.result.status === 'cancelled' ? 'mid_movement' : null;
      eventLogger.emit(
        'run_complete',
        buildRunCompletePayload(mapped.result, snapshotMeta, cancelPhase, currentMovementName),
      );
      callbacks?.onPieceComplete?.(mapped.result);
      return mapped.result;
    }
    currentMovementName = mapped.movement;
  }

  // max_movements 超過
  logger.warn(`[piece-runner] piece=${piece.name} exceeded max_movements=${maxMovements}`);
  const maxResult: PieceRunResult = {
    status: 'aborted',
    finalOutput: `Exceeded max movements (${maxMovements})`,
    movementHistory,
    abortReason: 'max_movements_exceeded',
    contextActions,
  };
  const snapshotMeta = writeMemorySnapshotIfNeeded(
    maxResult,
    workspacePath,
    runId,
    currentMovementName,
    lessonsAccumulator,
    totalSteps,
    eventLogger,
    options?.runtimeDir,
  );
  if (snapshotMeta) maxResult.memorySnapshotPath = snapshotMeta.pathRelative;
  eventLogger.emit(
    'run_complete',
    buildRunCompletePayload(maxResult, snapshotMeta, null, currentMovementName),
  );
  return maxResult;
  } finally {
    // Flush any pending recorder buffer and optionally stage a self-healing
    // patch script. Must run before browser cleanup so any last BrowseWeb
    // actions are already committed to the buffer.
    try {
      if (options?.taskId) {
        const cfg = loadConfig();
        flushAndStageRecording(
          options.taskId,
          // No-auth mode passes userId=null; fall back to the same 'local'
          // namespace as the ToolContext below, otherwise
          // flushAndStageRecording's `if (!ownerId) return` silently discards
          // every browser recording and self-healing patch in no-auth.
          options.userId ?? 'local',
          cfg.userFolderRoot ?? './data/users',
          // Spaces foundation: when the job ran inside a space, recordings (and
          // self-healing patches) land under that space's folder instead of the
          // owner's personal folder. Omitted → personal-folder behavior.
          options.folderContext,
        );
      }
    } catch (e) {
      logger.warn(`[piece-runner] flush-and-stage failed: ${(e as Error).message}`);
    }

    // Clean up this job's browser context (session cookies etc.)
    // The browser singleton stays alive for other jobs.
    try {
      const browserMod = await import('./tools/browser.js').catch(() => null);
      if (browserMod?.cleanupJobContext) await browserMod.cleanupJobContext(workspacePath);
    } catch { /* ignore cleanup errors */ }
  }
}

interface PreparedMovement {
  movement: Movement;
  ctx: ToolContext;
  enrichedInstruction: string;
  callbackBridge: AgentLoopCallbacks;
}

/**
 * Build everything executeMovement needs that doesn't change per-iteration:
 * the typed Movement, the ToolContext, the enriched instruction (with carried
 * verify-feedback + active checklist + sub-task / lessons hints), and a
 * callback bridge that records ContextActions while delegating to the
 * caller's callbacks.
 */
function prepareMovementContext(
  piece: PieceDef,
  movementDef: PieceDef['movements'][number],
  taskInstruction: string,
  workspacePath: string,
  toolsConfig: ToolsConfig | undefined,
  movementHistory: Array<{ name: string; result: MovementResult }>,
  lessonsAccumulator: Array<{ movement: string; lessons: string }>,
  contextActions: ContextAction[],
  callbacks: PieceRunCallbacks | undefined,
  options?: {
    spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
    hasPendingSubtasks?: () => Promise<boolean>;
    searchFilter?: SearchFilterConfig;
    customPiecesDir?: string | string[];
    vlmEnabled?: boolean;
    /** Traceability T-1: per-run event logger threaded into ToolContext. */
    eventLogger?: EventLogger;
    /** Mission Brief IO. Constructed by the worker layer (which knows the
     * local task ID). Threaded into ToolContext so the MissionUpdate tool
     * and buildSystemPrompt can read/write the per-task pinned memo. */
    missionBrief?: import('./tools/core.js').MissionBriefIO;
    /** Conversation recall IO (comments + transcript for THIS task). */
    taskConversation?: import('./tools/core.js').TaskConversationIO;
    /** Workspace-wide task search IO. */
    workspaceTaskSearch?: import('./tools/core.js').WorkspaceTaskSearchIO;
    /** Browser session keying — the local task ID this run is bound to. */
    taskId?: string;
    /** Task owner user.id — passed into ToolContext for noVNC auth. */
    userId?: string;
    /** Decrypted storageState for the bound browser_session_profile. */
    browserSessionState?: object;
    /** Profile id of the bound profile. */
    browserSessionProfileId?: number;
    /** Profile metadata used by BrowseWeb's expiry check. */
    browserSessionProfile?: { loggedInSelector: string | null; loginUrlPatterns: string[] };
    /** Worker-provided callback invoked when BrowseWeb detects auth expiry. */
    onAuthExpired?: (profileId: number, reason: string) => void;
    /** Phase 5 (MCP): job owner user.id, threaded into ToolContext.ownerId. */
    ownerId?: string | null;
    /** Phase 5 (MCP): job id, threaded into ToolContext.jobId. */
    jobId?: string | null;
    /** Phase 5 (MCP): MCP runtime config (timeouts + quotas). */
    mcpConfig?: import('./tools/core.js').ToolContext['mcpConfig'];
    /** Role of the job owner. Gates admin-only tool actions. Defaults to 'user'. */
    userRole?: 'admin' | 'user';
    /** Safety config — threaded so prepareMovementContext can propagate bashUnrestricted. */
    safetyConfig?: { bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off'; bashAllowNetwork?: boolean };
    skillCatalog?: import('./skills.js').SkillCatalog;
    /** Per-task option: when true, MCP tools are not loaded/dispatched. */
    mcpDisabled?: boolean;
    /** Per-task option: when true, skill index is not injected into the system prompt. */
    skillsDisabled?: boolean;
    /** Spaces foundation: resolved space folder. Threaded into ToolContext.folderContext. */
    folderContext?: { rootDir: string; leafId: string };
    /** Spaces foundation: space id. Threaded into ToolContext.spaceId (Phase 1 no-op). */
    spaceId?: string;
    /** Per-space Python package overlay `current` symlink path. Threaded into
     * ToolContext.pythonPackagesDir. undefined => feature disabled / no packages. */
    pythonPackagesDir?: string;
    /** Spaces foundation (plan 3): conflict detection for persistent workspaces.
     * Threaded into ToolContext.conflictDetection. Undefined => OFF (legacy). */
    conflictDetection?: boolean;
    /** 計画5: 実行ログ root。ctx.runtimeDir と checklist 読込に使う。
     * 未指定時は workspacePath/logs にフォールバック（後方互換）。 */
    runtimeDir?: string;
    /** 計画5 (spec §10.3): 競合台帳 dir。ctx.conflictDir に渡す。 */
    conflictDir?: string;
    /** Tool-request mechanism: per-task grant overlay (tool names a user
     * approved inline), unioned into every movement's effective allowed set. */
    grantedTools?: string[];
    /** Workspace tool policy (A3): pre-resolved set of allowed tools and the
     * edit flag for this job's space — the authoritative (and only) gate for
     * tool/edit availability. Absent (unit tests) → effective set is grantedTools
     * only and edit defaults to false. */
    workspaceTools?: { allowedTools: string[]; editAllowed: boolean };
    /** SSH workspace scoping: connection IDs registered to this job's space,
     * resolved by the worker when ssh is enabled in the workspace policy — the
     * only SSH connection gate. undefined/absent → empty (SSH tools reject).
     * Empty array = ssh enabled but no connections registered → clean rejection. */
    workspaceSshConnections?: string[];
    /** Tool-request mechanism: true when this run can pause for inline approval
     * (a reachable user — local task, not a subtask/headless run). */
    toolApprovalInteractive?: boolean;
    /** Tool-request mechanism: record an agent tool request / blocked call.
     * Bound to task/job/space by the worker; undefined => recording disabled. */
    recordToolRequest?: (req: {
      pieceName: string;
      movementName: string;
      toolName: string;
      reason?: string | null;
      category: 'requested' | 'blocked' | 'unknown';
      status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
    }) => void;
    /** Package-request mechanism: record an agent Python package request.
     * Bound to task/job/space by the worker only when the feature is enabled
     * AND a space is resolvable; undefined => feature unavailable this run. */
    recordPackageRequest?: (req: {
      pieceName?: string | null;
      movementName?: string | null;
      spec: string;
      normalizedName: string;
      reason?: string | null;
      status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
    }) => void;
    /** Package-request mechanism: packages already installed in the space
     * overlay (name→spec, version-aware). undefined => feature off. */
    installedPackages?: Array<{ name: string; spec: string }>;
    /** Package-request mechanism: names declined for this job (deny loop guard). */
    declinedPackages?: string[];
    /** File-provenance read IO (workspace-bound). Threaded into ToolContext. */
    fileProvenance?: import('./tools/core.js').FileProvenanceIO;
    /** File-provenance write recorder. Bound to task/job/space by the worker;
     * piece + movement injected here per movement. */
    recordFileProvenance?: (ev: import('./tools/core.js').FileProvenanceEvent & {
      pieceName: string;
      movementName: string;
    }) => void;
  },
): PreparedMovement {
  // Workspace tool policy is the ONLY source of tool / edit / SSH availability.
  // The worker resolves it once per job (resolveWorkspaceTools) and passes it in
  // as workspaceTools. grantedTools (per-task inline approvals) are unioned on
  // top so tool-request grants survive the transition. When workspaceTools is
  // absent (unit tests / legacy callers), the effective set is just the granted
  // overlay (empty by default) — pieces no longer declare their own tools.
  const wsPolicyBase = options?.workspaceTools?.allowedTools;
  const wsEditAllowed = options?.workspaceTools?.editAllowed;
  const effectiveAllowedTools = wsPolicyBase != null
    ? mergeToolNames(wsPolicyBase, options?.grantedTools)
    : mergeToolNames(options?.grantedTools);
  // Edit (Write/Edit) availability comes from the policy's editAllowed. When no
  // policy is provided, default to false (fail-closed).
  const effectiveEdit = wsEditAllowed != null ? wsEditAllowed : false;
  // SSH connection gate: the worker-resolved space-scoped list, or empty (no
  // registered connections → SSH tools cleanly reject). Never falls back to a
  // piece declaration — those no longer exist.
  const effectiveSshConnections = options?.workspaceSshConnections ?? [];

  const movement: Movement = {
    name: movementDef.name,
    edit: effectiveEdit,
    persona: movementDef.persona,
    instruction: movementDef.instruction,
    // Effective allowed set = resolved workspace policy ∪ inline grants.
    // META_TOOLS are added downstream by getToolDefs — not double-added here.
    allowedTools: effectiveAllowedTools,
    allowedSshConnections: effectiveSshConnections,
    rules: movementDef.rules,
    defaultNext: movementDef.default_next,
  };

  const ctx: ToolContext = {
    workspacePath,
    runtimeDir: options?.runtimeDir,
    editAllowed: effectiveEdit,
    vlmEnabled: options?.vlmEnabled,
    allowedCommands: movementDef.allowed_commands,
    bashUnrestricted: options?.safetyConfig?.bashUnrestricted,
    bashSandbox: options?.safetyConfig?.bashSandbox,
    bashAllowNetwork: options?.safetyConfig?.bashAllowNetwork,
    skillCatalog: options?.skillCatalog,
    userRole: options?.userRole,
    // SSH gate: worker-resolved space-scoped connection list (empty when ssh is
    // disabled in the policy or no connections are registered → clean rejection).
    allowedSshConnections: effectiveSshConnections,
    pieceName: piece.name,
    // Tool-request mechanism: movement identity + effective tool set for
    // RequestTool classification, and a recorder wrapped to inject piece +
    // movement (the worker provides the task/job/space-bound callback).
    movementName: movementDef.name,
    effectiveToolNames: movement.allowedTools,
    toolApprovalInteractive: options?.toolApprovalInteractive,
    recordToolRequest: options?.recordToolRequest
      ? (req) => options.recordToolRequest!({ ...req, pieceName: piece.name, movementName: movementDef.name })
      : undefined,
    // Package-request mechanism: recorder wrapped to inject piece + movement
    // (worker binds task/job/space); the installed-set is space-wide (no per-
    // movement injection needed).
    recordPackageRequest: options?.recordPackageRequest
      ? (req) => options.recordPackageRequest!({ ...req, pieceName: piece.name, movementName: movementDef.name })
      : undefined,
    installedPackages: options?.installedPackages,
    declinedPackages: options?.declinedPackages,
    toolsConfig,
    eventLogger: options?.eventLogger,
    searchFilter: options?.searchFilter,
    // Pass the full array so in-agent reads (ListPieces/GetPiece) can see ALL
    // custom dirs (per-user + global-custom). CreatePiece writes to dirs[0]
    // (the per-user dir) — handled inside pieces.ts.
    customPiecesDir: options?.customPiecesDir,
    folderContext: options?.folderContext,
    // Spaces foundation: thread space id for future per-space MCP/SSH
    // resolution. Phase 1 no-op (no consumer reads ctx.spaceId yet).
    spaceId: options?.spaceId,
    // Per-space Python package overlay `current` symlink path (resolved by the
    // worker from spaceId + config). Bash binds it read-only + puts it on
    // PYTHONPATH. undefined = feature disabled / no packages.
    pythonPackagesDir: options?.pythonPackagesDir,
    spawnSubTask: options?.spawnSubTask,
    hasPendingSubtasks: options?.hasPendingSubtasks,
    missionBrief: options?.missionBrief,
    taskConversation: options?.taskConversation,
    workspaceTaskSearch: options?.workspaceTaskSearch,
    // File provenance: read IO is workspace-bound; the write recorder is wrapped
    // to inject the current piece + movement (worker binds task/job/space).
    fileProvenance: options?.fileProvenance,
    recordFileProvenance: options?.recordFileProvenance
      ? (ev) => options.recordFileProvenance!({ ...ev, pieceName: piece.name, movementName: movementDef.name })
      : undefined,
    taskId: options?.taskId,
    // No-auth jobs have no owner (ownerId/userId null). Resolve a single 'local'
    // identity — matching where pieces already resolve (worker.ts: ownerId ?? 'local')
    // — so MCP (global servers via ctx.ownerId) and the user folder / memory
    // (ctx.userId) work in single-tenant deployments instead of being disabled.
    userId: options?.userId ?? 'local',
    browserSessionState: options?.browserSessionState,
    browserSessionProfileId: options?.browserSessionProfileId,
    browserSessionProfile: options?.browserSessionProfile,
    onAuthExpired: options?.onAuthExpired,
    ownerId: options?.ownerId ?? 'local',
    jobId: options?.jobId,
    mcpConfig: options?.mcpConfig,
    mcpQuotaState: { files: 0, bytes: 0 },
    mcpDisabled: options?.mcpDisabled,
    skillsDisabled: options?.skillsDisabled,
    conflictDetection: options?.conflictDetection,
    conflictDir: options?.conflictDir,
  };

  let enrichedInstruction = buildInstructionWithFeedback(taskInstruction, movementDef.name, movementHistory);
  // Follow-up detection: if the workspace already has user-visible output
  // from a previous turn (chat / local-task continuation), nudge the LLM
  // toward a checklist before diving in. Targets the observed pattern of
  // 2nd-turn requests (clarifications / refinements / deepening) where
  // the agent skips planning because "we have context already".
  const followupNotice = buildFollowupNotice(workspacePath);
  if (followupNotice) {
    enrichedInstruction += '\n\n' + followupNotice;
    options?.eventLogger?.emit('followup_detected', {
      workspacePath,
      movementName: movementDef.name,
    });
  }
  // P1-2: when completed subtask results are present (subtasks/N/result.md, written
  // by the worker on child completion), this run is resuming after a WAIT_SUBTASKS
  // park. The resumed movement re-runs from the original instruction, which would
  // otherwise tempt a second identical SpawnSubTask wave. Steer to "read results,
  // don't re-spawn".
  const subtaskResumeNotice = buildSubtaskResumeNotice(workspacePath);
  if (subtaskResumeNotice) {
    enrichedInstruction += '\n\n' + subtaskResumeNotice;
  }
  const checklistContext = buildChecklistContext(options?.runtimeDir ?? join(workspacePath, 'logs'));
  if (checklistContext) {
    enrichedInstruction += '\n\n' + checklistContext;
  }
  // Tell the LLM that decomposition is unavailable when this run is itself a
  // subtask (spawnSubTask not provided). Key the hint off the RESOLVED tool set:
  // only emit it when SpawnSubTask is enabled by the workspace policy (so the
  // agent would otherwise expect it) but can't be used at this depth. When the
  // policy doesn't grant SpawnSubTask at all, the hint would be noise.
  const spawnEnabledByPolicy = effectiveAllowedTools.includes('SpawnSubTask');
  if (!options?.spawnSubTask && spawnEnabledByPolicy) {
    enrichedInstruction += '\n\n【制約】このタスクはサブタスクとして実行中のため、SpawnSubTask は使用できません（これ以上の分解は不可）。delegate を使うか、自分で作業を完了させてください。';
  }
  if (lessonsAccumulator.length > 0) {
    enrichedInstruction += '\n\n' + buildLessonsContext(lessonsAccumulator);
  }

  const callbackBridge: AgentLoopCallbacks = {
    ...callbacks,
    onContextAction: (action) => {
      contextActions.push(action);
      callbacks?.onContextAction?.(action);
    },
    onContextUpdate: (payload) => {
      callbacks?.onContextUpdate?.(payload);
    },
  };

  return { movement, ctx, enrichedInstruction, callbackBridge };
}

type LoopGuardOutcome =
  | { kind: 'continue'; movementDef: PieceDef['movements'][number]; consecutive: number; maxConsecutive: number }
  | { kind: 'abort'; result: PieceRunResult };

/**
 * Apply the three pre-iteration gates of runPiece's main loop and update the
 * visit counters in place:
 *
 *   1. `cancelCheck()` returns true → cancellation result.
 *   2. `currentMovementName` doesn't match any movement → error result.
 *   3. Consecutive visits exceed the per-movement / default cap → loop-
 *      detection result.
 *
 * On 'continue' the caller gets the resolved movementDef plus the freshly-
 * incremented `consecutive` count and the applicable cap so it can be passed
 * straight into executeMovement as visitCount/maxVisits.
 */
function enforceLoopGuards(
  piece: PieceDef,
  currentMovementName: string,
  finalOutput: string,
  totalVisits: Record<string, number>,
  consecutiveVisits: Record<string, number>,
  defaultMaxConsecutive: number,
  movementHistory: Array<{ name: string; result: MovementResult }>,
  contextActions: ContextAction[],
  options?: { cancelCheck?: () => boolean; abortController?: AbortController },
): LoopGuardOutcome {
  if (options?.cancelCheck?.()) {
    logger.info(`[piece-runner] piece=${piece.name} cancelled before movement=${currentMovementName}`);
    options?.abortController?.abort();
    return {
      kind: 'abort',
      result: {
        status: 'cancelled',
        finalOutput: 'Job was cancelled by user request',
        movementHistory,
        abortReason: 'cancelled',
        contextActions,
      },
    };
  }

  // Terminal-sentinel gate. Engine fallback paths (the SpawnSubTask-skip branch
  // and the ASK-limit fallback) advance to `movementDef.default_next`, which is
  // legitimately COMPLETE/ABORT/ASK for many pieces. Those are terminal
  // sentinels, not movement names — resolve them here instead of letting the
  // lookup below fail with "Movement not found: COMPLETE".
  if (RESERVED_TERMINAL_NEXT_VALUES_PIECE_RUNNER.has(currentMovementName)) {
    logger.info(`[piece-runner] piece=${piece.name} resolved terminal sentinel '${currentMovementName}' reached via fallback path`);
    if (currentMovementName === 'COMPLETE') {
      return {
        kind: 'abort',
        result: { status: 'completed', finalOutput, movementHistory, abortReason: null, contextActions },
      };
    }
    // ABORT, plus the defensive ASK case. ASK cannot wait for a human here (no
    // resume movement survives the fallback), so it also ends as aborted — but
    // keep a distinct abortReason so the telemetry reflects what happened rather
    // than collapsing both into a generic 'movement_abort'.
    const sentinelAbortReason = currentMovementName === 'ASK' ? 'ask_limit_reached' : 'movement_abort';
    return {
      kind: 'abort',
      result: { status: 'aborted', finalOutput, movementHistory, abortReason: sentinelAbortReason, contextActions },
    };
  }

  const movementDef = piece.movements.find((m) => m.name === currentMovementName);
  if (!movementDef) {
    logger.error(`[piece-runner] movement not found: ${currentMovementName}`);
    return {
      kind: 'abort',
      result: {
        status: 'error',
        finalOutput: `Movement not found: ${currentMovementName}`,
        movementHistory,
        abortReason: 'movement_not_found',
        contextActions,
      },
    };
  }

  totalVisits[currentMovementName] = (totalVisits[currentMovementName] ?? 0) + 1;
  for (const key of Object.keys(consecutiveVisits)) {
    if (key !== currentMovementName) consecutiveVisits[key] = 0;
  }
  consecutiveVisits[currentMovementName] = (consecutiveVisits[currentMovementName] ?? 0) + 1;

  const consecutive = consecutiveVisits[currentMovementName]!;
  const maxConsecutive = movementDef.max_consecutive_revisits ?? defaultMaxConsecutive;

  if (consecutive > maxConsecutive) {
    logger.warn(`[piece-runner] piece=${piece.name} movement=${currentMovementName} consecutive visits ${consecutive} > ${maxConsecutive}, aborting`);
    return {
      kind: 'abort',
      result: {
        status: 'aborted',
        finalOutput: `Loop detected: ${currentMovementName} visited ${consecutive} consecutive times (limit: ${maxConsecutive})`,
        movementHistory,
        abortReason: 'loop_detected',
        contextActions,
      },
    };
  }

  return { kind: 'continue', movementDef, consecutive, maxConsecutive };
}

/**
 * Translate the inner `executeMovement` result into either:
 *   - `{ kind: 'next'; movement }` — continue the runPiece loop with the
 *     given movement name as the next step
 *   - `{ kind: 'done'; result }`   — runPiece should stop and return this
 *     PieceRunResult (caller fires onPieceComplete)
 *
 * Centralizes the COMPLETE / ABORT / ASK / WAIT_SUBTASKS /
 * WAITING_HUMAN_BROWSER / null / unknown branches that used to be a
 * 100-line if/else cascade in runPiece.
 */
function mapMovementResult(
  result: MovementResult,
  ctx: {
    piece: PieceDef;
    currentMovementName: string;
    movementDef: PieceDef['movements'][number];
    askCount: number;
    maxAskPerJob: number;
    finalOutput: string;
    movementHistory: Array<{ name: string; result: MovementResult }>;
    contextActions: ContextAction[];
  },
):
  | { kind: 'next'; movement: string }
  | { kind: 'done'; result: PieceRunResult } {
  const { piece, currentMovementName, movementDef, finalOutput, movementHistory, contextActions } = ctx;

  if (result.next === 'COMPLETE') {
    logger.info(`[piece-runner] piece=${piece.name} COMPLETE`);
    return {
      kind: 'done',
      result: { status: 'completed', finalOutput, movementHistory, abortReason: null, contextActions },
    };
  }

  // Cancellation (user cancel OR hard deadline) is classified by abortCode, not
  // by string-matching the output. The old `output.includes('cancelled')` both
  // (a) collapsed deadline and user cancel into one indistinguishable outcome
  // and (b) misclassified any genuine abort whose reason merely contained the
  // word "cancelled". The abort code is set at the single cancel chokepoint in
  // agent-loop (buildCancelResult).
  if (result.next === 'ABORT' && isCancellationAbortCode(result.abortCode)) {
    logger.info(`[piece-runner] piece=${piece.name} ${result.abortCode} during movement=${currentMovementName}`);
    return {
      kind: 'done',
      result: {
        status: 'cancelled',
        finalOutput: result.output,
        movementHistory,
        abortReason: result.abortCode ?? 'cancelled',
        contextActions,
      },
    };
  }

  if (result.next === 'ABORT') {
    const abortReason = result.abortCode ?? 'movement_abort';
    logger.warn(`[piece-runner] piece=${piece.name} ABORT code=${abortReason}`);
    return {
      kind: 'done',
      result: { status: 'aborted', finalOutput, movementHistory, abortReason, contextActions },
    };
  }

  if (result.next === 'ASK') {
    const askCount = ctx.askCount + 1;
    if (askCount > ctx.maxAskPerJob) {
      logger.warn(`[piece-runner] piece=${piece.name} ASK limit reached (${askCount}/${ctx.maxAskPerJob})`);
      const fallbackNext = movementDef.default_next
        ?? movementDef.rules?.find((r) => r.next !== currentMovementName && r.next !== 'ASK' && r.next !== 'ABORT')?.next;
      if (fallbackNext) return { kind: 'next', movement: fallbackNext };
      return {
        kind: 'done',
        result: {
          status: 'aborted',
          finalOutput: 'ASK limit reached and no forward transition available',
          movementHistory,
          abortReason: 'ask_limit_reached',
          contextActions,
        },
      };
    }
    logger.info(`[piece-runner] piece=${piece.name} ASK (${askCount}/${ctx.maxAskPerJob}): ${result.output}`);
    return {
      kind: 'done',
      result: {
        status: 'waiting_human',
        finalOutput: result.output,
        movementHistory,
        resumeMovement: currentMovementName,
        abortReason: null,
        contextActions,
      },
    };
  }

  if (result.next === 'WAIT_SUBTASKS') {
    // Honor a tool-supplied resume target: WaitSubTask and the complete-time
    // auto-park set resumeMovement to the CURRENT movement so the agent re-enters
    // it after children finish (subtasks/*/result.md present). Fall back to
    // default_next for the legacy transition-rule path (research decompose → aggregate).
    const resumeMovement = result.resumeMovement ?? movementDef.default_next ?? null;
    logger.info(`[piece-runner] piece=${piece.name} WAIT_SUBTASKS resume=${resumeMovement}`);
    return {
      kind: 'done',
      result: {
        status: 'waiting_subtasks',
        finalOutput: result.output,
        movementHistory,
        resumeMovement,
        abortReason: null,
        contextActions,
      },
    };
  }

  if (result.next === 'WAITING_HUMAN_BROWSER') {
    logger.info(`[piece-runner] piece=${piece.name} WAITING_HUMAN_BROWSER sessionId=${result.browserSessionId}`);
    return {
      kind: 'done',
      result: {
        status: 'waiting_human',
        finalOutput: result.output,
        movementHistory,
        resumeMovement: currentMovementName,
        abortReason: null,
        contextActions,
        waitReason: result.waitReason ?? 'browser_login',
        browserSessionId: result.browserSessionId ?? null,
      },
    };
  }

  if (result.next === 'WAITING_HUMAN_TOOL_REQUEST') {
    logger.info(`[piece-runner] piece=${piece.name} WAITING_HUMAN_TOOL_REQUEST movement=${currentMovementName}`);
    return {
      kind: 'done',
      result: {
        status: 'waiting_human',
        finalOutput: result.output,
        movementHistory,
        resumeMovement: currentMovementName,
        abortReason: null,
        contextActions,
        waitReason: result.waitReason ?? 'tool_request',
      },
    };
  }

  if (result.next === 'WAITING_HUMAN_PACKAGE_REQUEST') {
    logger.info(`[piece-runner] piece=${piece.name} WAITING_HUMAN_PACKAGE_REQUEST movement=${currentMovementName}`);
    return {
      kind: 'done',
      result: {
        status: 'waiting_human',
        finalOutput: result.output,
        movementHistory,
        resumeMovement: currentMovementName,
        abortReason: null,
        contextActions,
        waitReason: result.waitReason ?? 'package_request',
      },
    };
  }

  if (result.next === null) {
    logger.warn(`[piece-runner] piece=${piece.name} movement=${currentMovementName} returned null next`);
    return {
      kind: 'done',
      result: {
        status: 'error',
        finalOutput: 'Movement ended without transition',
        movementHistory,
        abortReason: 'movement_without_transition',
        contextActions,
      },
    };
  }

  return { kind: 'next', movement: result.next };
}

/**
 * 過去の verify フィードバックを累積して taskInstruction に追記する。
 * execute/process/plan/analyze にのみ付加し、レビューの指摘が再発しないようにする。
 */
function buildInstructionWithFeedback(
  original: string,
  movementName: string,
  history: Array<{ name: string; result: MovementResult }>,
): string {
  if (!shouldCarryReviewFeedback(movementName) || history.length === 0) return original;

  const feedbackBlocks = history
    .filter((entry) => REVIEW_FEEDBACK_MOVEMENTS.has(entry.name))
    .map((entry, index) => {
      const trimmed = truncateFeedback(entry.result.output);
      if (!trimmed) return null;
      return `### ${entry.name} 指摘 ${index + 1}\n${trimmed}`;
    })
    .filter((entry): entry is string => Boolean(entry));

  if (feedbackBlocks.length === 0) return original;

  const combined = trimCombinedFeedback(feedbackBlocks.join('\n\n'));
  return `${original}\n\n---\nこれまでのレビュー指摘（すべて対応すること）:\n${combined}`;
}

function truncateFeedback(text: string): string {
  const normalized = text.trim();
  if (!normalized) return '';
  if (normalized.length <= MAX_FEEDBACK_LENGTH) return normalized;
  return `${normalized.slice(0, MAX_FEEDBACK_LENGTH)}...`;
}

function trimCombinedFeedback(text: string): string {
  if (text.length <= MAX_COMBINED_FEEDBACK_LENGTH) return text;
  return `${text.slice(0, MAX_COMBINED_FEEDBACK_LENGTH)}...`;
}

function appendWorkspaceChangeSummary(reviewOutput: string, workspacePath: string): string {
  const sections: string[] = [];
  const status = runGitSummary(
    ['status', '--short', '--untracked-files=all', '--', '.', ':(exclude)input/**', ':(exclude)logs/**'],
    workspacePath,
    MAX_GIT_STATUS_LENGTH,
  );
  if (status) {
    sections.push(`## 現在の変更状況\n\`\`\`\n${status}\n\`\`\``);
  }
  const diff = runGitSummary(
    ['diff', '--no-ext-diff', '--no-color', '--unified=0', '--', '.', ':(exclude)input/**', ':(exclude)logs/**'],
    workspacePath,
    MAX_GIT_DIFF_LENGTH,
  );
  if (diff) {
    sections.push(`## 変更差分（抜粋）\n\`\`\`diff\n${diff}\n\`\`\``);
  }
  if (sections.length === 0) return reviewOutput;
  return `${reviewOutput.trim()}\n\n${sections.join('\n\n')}`.trim();
}

function shouldCarryReviewFeedback(movementName: string | undefined): boolean {
  return Boolean(movementName && REVIEW_FEEDBACK_TARGET_MOVEMENTS.has(movementName));
}

const MAX_LESSONS_LENGTH = 2000;

function buildLessonsContext(lessons: Array<{ movement: string; lessons: string }>): string {
  const header = '---\n## 前のステップで得た教訓';
  const entries = lessons.map(l => `- [${l.movement}] ${l.lessons}`);

  let combined = `${header}\n${entries.join('\n')}`;
  while (combined.length > MAX_LESSONS_LENGTH && entries.length > 1) {
    entries.shift();
    combined = `${header}\n${entries.join('\n')}`;
  }

  if (combined.length > MAX_LESSONS_LENGTH) {
    combined = combined.substring(0, MAX_LESSONS_LENGTH) + '...';
  }

  return combined;
}

function writeLessonLog(logsRoot: string, pieceName: string, movement: string, lessons: string): void {
  try {
    const logPath = join(logsRoot, 'lessons.jsonl');
    const entry = JSON.stringify({
      timestamp: new Date().toISOString(),
      piece: pieceName,
      movement,
      lessons,
    });
    appendFileSync(logPath, entry + '\n');
  } catch (err) {
    logger.warn(`[piece-runner] failed to write lessons log: ${err}`);
  }
}

function runGitSummary(args: string[], workspacePath: string, maxLength: number): string {
  try {
    const output = execFileSync('git', args, {
      cwd: workspacePath,
      encoding: 'utf-8',
      timeout: 5_000,
      stdio: ['ignore', 'pipe', 'pipe'],
    }).trim();
    if (!output) return '';
    if (output.length <= maxLength) return output;
    return `${output.slice(0, maxLength)}...`;
  } catch {
    return '';
  }
}

const MAX_CHECKLIST_FILES = 5;
const MAX_REMAINING_ITEMS = 20;
const MAX_CHECKLIST_INJECTION_LENGTH = 2000;

const FOLLOWUP_OUTPUT_DIRS = ['output', 'subtasks'] as const;
const FOLLOWUP_HIDDEN_FILE_RE = /^\./;

/**
 * Returns a non-empty notice string when the workspace already contains
 * user-visible artifacts from a prior turn (chat continuation / resumed
 * local task / etc.). Used by `prepareMovementContext` to remind the LLM
 * that the current request is a follow-up — these turns are observed to
 * skip checklist creation more often than initial requests because the
 * model assumes the context has already been planned.
 *
 * Detection is intentionally cheap: if either `output/` or `subtasks/`
 * exists with at least one non-hidden file, the task is considered a
 * follow-up.
 */
export function buildFollowupNotice(workspacePath: string): string {
  let detected = false;
  for (const sub of FOLLOWUP_OUTPUT_DIRS) {
    const dir = join(workspacePath, sub);
    if (!existsSync(dir)) continue;
    let entries: string[];
    try {
      entries = readdirSync(dir);
    } catch {
      continue;
    }
    if (entries.some((f) => !FOLLOWUP_HIDDEN_FILE_RE.test(f))) {
      detected = true;
      break;
    }
  }
  if (!detected) return '';
  return [
    '## 【継続タスク】 (follow-up turn detected)',
    'output/ または subtasks/ に既存の成果物が見つかりました。これはユーザーとの 2 回目以降のやり取りです。',
    '- 補足質問・修正依頼・深掘りなど、初回より作業範囲が曖昧で複雑化しやすい段階です',
    '- **着手の前に、まず `CreateChecklist` で今回の対応計画を可視化してください**（既存チェックリストがある場合は `GetChecklist` で内容を確認してから判断する）',
    '- 「単純な追加質問だから不要」と判断する場合でも、複数ツール呼び出しが想定されるなら作る方を優先してください',
  ].join('\n');
}

/**
 * When this job's workspace has completed subtask results (subtasks/N/result.md,
 * written by the worker when a child reaches a terminal state), the run is almost
 * certainly resuming after a WAIT_SUBTASKS park. The resumed movement re-runs from
 * the original instruction, which would otherwise tempt a second identical
 * SpawnSubTask wave (codex P1-2). Steer the LLM to read the results and synthesize.
 */
export function buildSubtaskResumeNotice(workspacePath: string): string {
  const subtasksDir = join(workspacePath, 'subtasks');
  if (!existsSync(subtasksDir)) return '';
  let done = 0;
  try {
    for (const entry of readdirSync(subtasksDir)) {
      if (existsSync(join(subtasksDir, entry, 'result.md'))) done += 1;
    }
  } catch {
    return '';
  }
  if (done === 0) return '';
  return [
    '## 【サブタスク完了】 (resumed after WAIT_SUBTASKS)',
    `${done} 件のサブタスク結果が subtasks/*/result.md に揃っています。`,
    '- まず各 result.md を読んで統合してください',
    '- 同じ依頼で SpawnSubTask を再度呼ばないこと（既に実行済み）。追加の分解が本当に必要な場合のみ新規に spawn する',
    '- 統合できたら complete で回答を返してください',
  ].join('\n');
}

/**
 * workspace/logs/checklists/ にあるチェックシートを読み込み、
 * movement 開始時に system prompt に注入するための要約テキストを生成する。
 */
export function buildChecklistContext(logsRoot: string): string {
  const dir = join(logsRoot, 'checklists');
  if (!existsSync(dir)) return '';

  let files: string[];
  try {
    files = readdirSync(dir).filter(f => f.endsWith('.json'));
  } catch {
    return '';
  }
  if (files.length === 0) return '';

  // 更新日時が新しい順にソート、最大5件
  const checklists = files
    .map(f => {
      try {
        const data = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
        return data;
      } catch {
        return null;
      }
    })
    .filter(Boolean)
    .sort((a: any, b: any) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
    .slice(0, MAX_CHECKLIST_FILES);

  if (checklists.length === 0) return '';

  const lines: string[] = ['## 作業チェックシート'];

  for (const cl of checklists) {
    const s = cl.summary;
    lines.push(`### ${cl.name} (${s.done}/${s.total}完了, ${s.failed}件失敗, ${s.remaining}件残り)`);

    // 残りアイテム（先頭20件）
    const remaining = cl.items.filter((i: any) => i.status === 'pending');
    if (remaining.length > 0) {
      const shown = remaining.slice(0, MAX_REMAINING_ITEMS).map((i: any) => i.id).join(', ');
      const suffix = remaining.length > MAX_REMAINING_ITEMS ? ` (他${remaining.length - MAX_REMAINING_ITEMS}件)` : '';
      lines.push(`残りアイテム: ${shown}${suffix}`);
    }

    // 失敗アイテム
    const failed = cl.items.filter((i: any) => i.status === 'failed');
    if (failed.length > 0) {
      for (const f of failed) {
        lines.push(`失敗アイテム: ${f.id} (error: ${f.error ?? 'unknown'})`);
      }
    }
  }

  const text = lines.join('\n');
  return text.length > MAX_CHECKLIST_INJECTION_LENGTH
    ? text.slice(0, MAX_CHECKLIST_INJECTION_LENGTH) + '\n...(省略)'
    : text;
}

/**
 * Cancel-traceability PR1: persist a forensic run snapshot to
 * disk on terminal non-success so the user can audit "what did the agent know
 * at the moment it stopped?" forensically.
 *
 * schemaVersion=2 (forensics bundle): also capture the LLM's final message
 * (`finalOutput`, which holds `complete.abort_reason` on agent-driven aborts),
 * a compact `movementHistory`, accumulated `lessons`, `contextActions`, and
 * a pointer to `logs/events.jsonl` so a reader can answer "how did we get
 * here?" without cross-referencing multiple files. Earlier v1 snapshots had
 * only `memory.{facts,decisions,openQuestions,doNotRepeat}` which routinely
 * came back empty on agent-self-aborts, leaving the actual reason untraceable.
 *
 * Codex review reflection (cancel-traceability second opinion):
 *   - Trigger set: `cancelled | error | aborted | waiting_human`. Skip
 *     `completed` (success path doesn't need forensics) and `waiting_subtasks`
 *     (transient pause; the run is not actually terminating).
 *   - Sidecar JSON, NOT inline event payload. Memory can hold dozens of
 *     facts/decisions; payload-only would routinely overflow the 8KB/event
 *     and 2KB/string caps in event-log.
 *   - Filename: `logs/memory-snapshot-{status}-{runId}-{ts}.json`. runId alone
 *     is sufficient for uniqueness (a single run terminates once); the
 *     timestamp is for human readability.
 *   - `movementHistory.outputPreview` is capped at 500 chars per entry to
 *     keep the file under ~50KB even after many movements; the full last
 *     output lives uncapped at top-level `finalOutput`.
 */
const SNAPSHOT_TRIGGER_STATUSES: ReadonlySet<PieceRunResult['status']> = new Set([
  'cancelled',
  'error',
  'aborted',
  'waiting_human',
]);

const SNAPSHOT_SCHEMA_VERSION = 2;
const MOVEMENT_OUTPUT_PREVIEW_CAP = 500;
const LESSON_PREVIEW_CAP = 1_000;

interface MemorySnapshotMeta {
  pathRelative: string;
  bytes: number;
  movements: number;
  lessons: number;
}

function summarizeMovementHistory(
  history: Array<{ name: string; result: MovementResult }>,
): Array<{
  name: string;
  next: string | null;
  toolsUsed: string[];
  outputPreview: string;
  outputTruncated: boolean;
  hasLessons: boolean;
}> {
  return history.map(({ name, result }) => {
    const full = result.output ?? '';
    const truncated = full.length > MOVEMENT_OUTPUT_PREVIEW_CAP;
    return {
      name,
      next: result.next ?? null,
      toolsUsed: Array.isArray(result.toolsUsed) ? result.toolsUsed : [],
      outputPreview: truncated ? full.slice(0, MOVEMENT_OUTPUT_PREVIEW_CAP) : full,
      outputTruncated: truncated,
      hasLessons: Boolean(result.lessons),
    };
  });
}

function summarizeLessons(
  lessons: Array<{ movement: string; lessons: string }>,
): Array<{ movement: string; lessons: string; truncated: boolean }> {
  return lessons.map(({ movement, lessons: text }) => {
    const truncated = text.length > LESSON_PREVIEW_CAP;
    return {
      movement,
      lessons: truncated ? text.slice(0, LESSON_PREVIEW_CAP) : text,
      truncated,
    };
  });
}

function writeMemorySnapshotIfNeeded(
  result: PieceRunResult,
  workspacePath: string,
  runId: string,
  currentMovement: string | null,
  lessonsAccumulator: Array<{ movement: string; lessons: string }>,
  totalSteps: number,
  eventLogger: EventLogger,
  // 計画5: 実行ログ root。未指定時は workspacePath/logs（後方互換）。
  // Phase A では undefined なので relPath / absPath とも現行と byte 一致。
  logsRoot?: string,
): MemorySnapshotMeta | null {
  if (!workspacePath) return null;
  if (!SNAPSHOT_TRIGGER_STATUSES.has(result.status)) return null;

  const writtenAt = new Date().toISOString();
  const tsCompact = writtenAt.replace(/[:.]/g, '').replace('T', 'T').replace(/Z$/, 'Z');
  const snapshotFile = `memory-snapshot-${result.status}-${runId}-${tsCompact}.json`;
  const relPath = `logs/${snapshotFile}`;
  const absPath = logsRoot
    ? join(logsRoot, snapshotFile)
    : join(workspacePath, relPath);

  const movementSummary = summarizeMovementHistory(result.movementHistory ?? []);
  const lessonsSummary = summarizeLessons(lessonsAccumulator);

  const fileContent = {
    schemaVersion: SNAPSHOT_SCHEMA_VERSION,
    runId,
    writtenAt,
    status: result.status,
    abortReason: result.abortReason ?? null,
    currentMovement,
    finalOutput: result.finalOutput ?? '',
    movementHistory: movementSummary,
    lessons: lessonsSummary,
    contextActions: result.contextActions ?? [],
    stats: {
      totalSteps,
      movementCount: movementSummary.length,
      lessonsCount: lessonsSummary.length,
    },
    eventsLogRelative: 'logs/events.jsonl',
  };

  try {
    writeAtomicJson(absPath, fileContent);
    const bytes = Buffer.byteLength(JSON.stringify(fileContent), 'utf-8');
    const meta: MemorySnapshotMeta = {
      pathRelative: relPath,
      bytes,
      movements: movementSummary.length,
      lessons: lessonsSummary.length,
    };
    eventLogger.emit('memory_snapshot_written', {
      status: result.status,
      path: relPath,
      bytes,
      movements: meta.movements,
      lessons: meta.lessons,
      currentMovement,
    });
    return meta;
  } catch (err) {
    const message = (err as Error).message;
    logger.warn(`[piece-runner] memory snapshot write failed: ${message}`);
    eventLogger.emit('memory_snapshot_failed', {
      status: result.status,
      path: relPath,
      error: message,
    });
    return null;
  }
}

function buildRunCompletePayload(
  result: PieceRunResult,
  snapshotMeta: MemorySnapshotMeta | null,
  cancelPhase: 'before_movement' | 'mid_movement' | null,
  currentMovement: string | null,
): Record<string, unknown> {
  const payload: Record<string, unknown> = {
    status: result.status,
    abortReason: result.abortReason ?? undefined,
  };
  if (snapshotMeta) payload.memorySnapshotPath = snapshotMeta.pathRelative;
  if (result.status === 'cancelled' && cancelPhase) {
    payload.cancel = {
      phase: cancelPhase,
      movement: currentMovement,
      snapshotPath: snapshotMeta?.pathRelative,
    };
  }
  return payload;
}
