import { readFileSync, existsSync, readdirSync, appendFileSync } from 'fs';
import { join } from 'path';
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 { ContextManager, type ContextAction } from './context-manager.js';
import { ToolResultCache } from './context/tool-result-cache.js';
import { WorkspaceMemory, type LineageEntry } from './context/workspace-memory.js';
import { buildMemoryHandoff, writeHandoffFile, readHandoffFile } from './context/memory-handoff.js';
import { buildMemoryDelta, writeDeltaFile, readDeltaFile, type ChildPieceStatus } from './context/memory-delta.js';
import { prefixWorkspacePath } from './context/path-normalize.js';
import { writeAtomicJson, readSafeJson, quarantineCorruptFile, type AtomicJsonSchema } 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';

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
  movements: Array<{
    name: string;
    edit: boolean;
    persona: string;
    instruction: string;
    allowed_tools: string[];
    allowed_commands?: string[];
    /**
     * Phase 4: per-movement SSH connection allowlist. UUID list, or `['*']`
     * for "any registered connection". Required (must be present, may be
     * empty) when allowed_tools contains SshExec / SshUpload / SshDownload —
     * Phase 7 tools reject with `no_allowed_connections_declared` if missing.
     * Grants and ownership are still checked separately (defense in depth).
     */
    allowed_ssh_connections?: 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;
}

/**
 * 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.`,
    );
  }
  const sshOffenders = validateAllowedSshConnections(piece);
  if (sshOffenders.length > 0) {
    throw new Error(
      `Piece "${piece.name}" has invalid allowed_ssh_connections:\n  ${sshOffenders.join('\n  ')}`,
    );
  }
}

/**
 * Phase 4: SSH tool names that require a per-movement `allowed_ssh_connections`
 * declaration. Kept here (not imported from tools/ssh.ts) so piece validation
 * has no runtime dependency on the SSH module — pieces can be validated even
 * when SSH is feature-disabled.
 */
const SSH_TOOL_NAMES: ReadonlySet<string> = new Set([
  'SshExec', 'SshUpload', 'SshDownload', 'SshListConnections',
  'SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot', 'SshConsoleRun',
]);

/**
 * Loose connection-id format: lowercase hex / digits / hyphens, 8+ chars.
 * Matches randomUUID() output but stays liberal in case the id scheme
 * changes later. `*` is a separately allowed wildcard.
 */
const ALLOWED_SSH_ID = /^[a-f0-9-]{8,}$/;

/**
 * Phase 4: validate `allowed_ssh_connections` consistency on each movement.
 * - Each entry must be `*` or a loose-UUID-ish string.
 * - Movements whose `allowed_tools` include any SSH tool MUST declare
 *   `allowed_ssh_connections` (may be empty for "deny all", but must be
 *   present so the intent is explicit).
 * Returns a list of human-readable error strings. Callers compose the
 * outer error message ("Piece X has invalid ...").
 */
export function validateAllowedSshConnections(piece: PieceDef): string[] {
  if (!Array.isArray(piece.movements)) return [];
  const offenders: string[] = [];
  for (const movement of piece.movements) {
    if (!movement) continue;
    const list = movement.allowed_ssh_connections;
    const tools = Array.isArray(movement.allowed_tools) ? movement.allowed_tools : [];
    const hasSshTool = tools.some((t) => typeof t === 'string' && SSH_TOOL_NAMES.has(t));
    if (list === undefined) {
      if (hasSshTool) {
        offenders.push(
          `movement="${movement.name}" uses SSH tool(s) but allowed_ssh_connections is not declared (required even if empty)`,
        );
      }
      continue;
    }
    if (!Array.isArray(list)) {
      offenders.push(
        `movement="${movement.name}" allowed_ssh_connections must be an array (got ${typeof list})`,
      );
      continue;
    }
    for (let i = 0; i < list.length; i++) {
      const entry = list[i];
      if (typeof entry !== 'string') {
        offenders.push(
          `movement="${movement.name}" allowed_ssh_connections[${i}] must be a string (got ${typeof entry})`,
        );
        continue;
      }
      if (entry !== '*' && !ALLOWED_SSH_ID.test(entry)) {
        offenders.push(
          `movement="${movement.name}" allowed_ssh_connections[${i}]="${entry}" must be '*' or a lowercase hex/hyphen id (8+ chars)`,
        );
      }
    }
  }
  return offenders;
}

// 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;
}

// 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 }>;
    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;
    /** 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;
    /** Shared knowledge notes: inject-mode subscriptions. When set, the
     * system prompt will include a "## Subscribed Notes" section. */
    notesService?: import('./tools/core.js').ToolContext['notesService'];
    /** Resolved inject config (merged defaults). Forwarded alongside notesService. */
    notesInjectConfig?: import('./tools/core.js').ToolContext['notesInjectConfig'];
    /** Org IDs for the job owner — used by visibility checks in notesService. */
    notesUserOrgIds?: string[];
    /** Role for the job owner. Defaults to 'user'. */
    notesUserRole?: '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 }>>;
  },
): Promise<PieceRunResult> {
  const movementHistory: Array<{ name: string; result: MovementResult }> = [];
  const contextActions: ContextAction[] = [];
  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();
  // Cross-movement structured observations (Phase 3). Shared lifetime with
  // toolResultCache; Edit/Write/Bash invalidations apply to both.
  const workspaceMemory = new WorkspaceMemory();

  // 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();
  const eventLogger: EventLogger = workspacePath
    ? createFileEventLogger({ workspacePath, runId })
    : new NoopEventLogger();
  eventLogger.emit('run_start', {
    pieceName: piece.name,
    parentJobId: options?.parentJobId,
    childWorkspaceRelative: options?.childWorkspaceRelative,
    resumeMovement: options?.resumeMovement,
    initialMovement: piece.initial_movement,
  });

  // Phase 5: if this is a subtask, absorb the parent's handoff before any
  // movement runs so the LLM sees inherited facts in the very first
  // system prompt.
  applyHandoffIfPresent(workspacePath, workspaceMemory, eventLogger);

  // Phase 5 PR2: restore previously-absorbed deltaIds so `waiting_subtasks
  // → resume` doesn't re-merge child deltas. Then sweep finished subtasks
  // for new deltas and absorb each idempotently.
  restoreAbsorbedDeltaIds(workspacePath, workspaceMemory);
  absorbReadyChildDeltas(workspacePath, workspaceMemory, eventLogger);

  // Phase 5: wrap spawnSubTask so that after a subtask is created, we
  // serialize this piece's current memory snapshot into the child's
  // workspace as `input/memory-handoff.json`. The wrapper is a no-op if
  // the caller didn't provide a jobId (no parent identity to record).
  const wrappedSpawnSubTask = options?.spawnSubTask
    ? wrapSpawnSubTaskWithHandoff(options.spawnSubTask, workspaceMemory, options.jobId, eventLogger)
    : undefined;

  try {
  while (totalSteps < maxMovements) {
    const guard = enforceLoopGuards(
      piece,
      currentMovementName,
      totalVisits,
      consecutiveVisits,
      defaultMaxConsecutive,
      movementHistory,
      contextActions,
      options,
    );
    if (guard.kind === 'abort') {
      writeSubtaskDeltaIfChild(guard.result, workspacePath, workspaceMemory, options, eventLogger);
      const snapshotMeta = writeMemorySnapshotIfNeeded(
        guard.result,
        workspacePath,
        runId,
        currentMovementName,
        workspaceMemory,
        lessonsAccumulator,
        totalSteps,
        eventLogger,
      );
      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,
      // Phase 5: substitute the handoff-wrapped spawnSubTask so child
      // workspaces receive memory handoffs automatically.
      // Traceability T-1: thread the event logger through so every tool
      // dispatch lands in events.jsonl.
      options
        ? { ...options, spawnSubTask: wrappedSpawnSubTask, eventLogger }
        : { eventLogger },
    );

    // SpawnSubTask が使えない状態で SpawnSubTask 必須の movement に入る場合、スキップして default_next へ
    if (
      !options?.spawnSubTask &&
      movementDef.allowed_tools.includes('SpawnSubTask') &&
      movementDef.default_next
    ) {
      logger.info(`[piece-runner] piece=${piece.name} skipping ${currentMovementName} (SpawnSubTask unavailable at depth limit), advancing to ${movementDef.default_next}`);
      const skipResult: MovementResult = { next: movementDef.default_next, output: 'Skipped: SpawnSubTask unavailable at depth limit', toolsUsed: [] };
      callbackBridge.onMovementComplete?.(currentMovementName, skipResult);
      movementHistory.push({ name: currentMovementName, result: skipResult });
      currentMovementName = movementDef.default_next;
      totalSteps++;
      continue;
    }

    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,
      workspaceMemory,
      handoffContext: options?.handoffContext,
      checkInterjections: options?.checkInterjections,
    });

    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(workspacePath, 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') {
      writeSubtaskDeltaIfChild(mapped.result, workspacePath, workspaceMemory, options, eventLogger);
      const snapshotMeta = writeMemorySnapshotIfNeeded(
        mapped.result,
        workspacePath,
        runId,
        currentMovementName,
        workspaceMemory,
        lessonsAccumulator,
        totalSteps,
        eventLogger,
      );
      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,
  };
  writeSubtaskDeltaIfChild(maxResult, workspacePath, workspaceMemory, options, eventLogger);
  const snapshotMeta = writeMemorySnapshotIfNeeded(
    maxResult,
    workspacePath,
    runId,
    currentMovementName,
    workspaceMemory,
    lessonsAccumulator,
    totalSteps,
    eventLogger,
  );
  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',
        );
      }
    } 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 }>;
    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;
    /** 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'];
    /** Shared knowledge notes service (optional). Forwarded into ToolContext. */
    notesService?: import('./tools/core.js').ToolContext['notesService'];
    /** Resolved notes inject config. Forwarded into ToolContext. */
    notesInjectConfig?: import('./tools/core.js').ToolContext['notesInjectConfig'];
    /** Org IDs for the job owner. */
    notesUserOrgIds?: string[];
    /** Role for the job owner. */
    notesUserRole?: '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;
  },
): PreparedMovement {
  const movement: Movement = {
    name: movementDef.name,
    edit: movementDef.edit,
    persona: movementDef.persona,
    instruction: movementDef.instruction,
    allowedTools: movementDef.allowed_tools,
    allowedSshConnections: movementDef.allowed_ssh_connections,
    rules: movementDef.rules,
    defaultNext: movementDef.default_next,
  };

  const ctx: ToolContext = {
    workspacePath,
    editAllowed: movementDef.edit,
    vlmEnabled: options?.vlmEnabled,
    allowedCommands: movementDef.allowed_commands,
    bashUnrestricted: options?.safetyConfig?.bashUnrestricted,
    bashSandbox: options?.safetyConfig?.bashSandbox,
    bashAllowNetwork: options?.safetyConfig?.bashAllowNetwork,
    skillCatalog: options?.skillCatalog,
    allowedSshConnections: movementDef.allowed_ssh_connections,
    pieceName: piece.name,
    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,
    spawnSubTask: options?.spawnSubTask,
    missionBrief: options?.missionBrief,
    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 / notes
    // (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 },
    notesService: options?.notesService,
    notesInjectConfig: options?.notesInjectConfig,
    notesUserOrgIds: options?.notesUserOrgIds,
    notesUserRole: options?.notesUserRole,
    mcpDisabled: options?.mcpDisabled,
    skillsDisabled: options?.skillsDisabled,
  };

  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,
    });
  }
  const checklistContext = buildChecklistContext(workspacePath);
  if (checklistContext) {
    enrichedInstruction += '\n\n' + checklistContext;
  }
  // Tell the LLM that decomposition is unavailable when this run is itself a
  // subtask (spawnSubTask not provided). Only emit the hint when the piece
  // actually has a movement that uses SpawnSubTask, otherwise it's noise.
  const pieceUsesSpawn = piece.movements.some((m) => m.allowed_tools.includes('SpawnSubTask'));
  if (!options?.spawnSubTask && pieceUsesSpawn) {
    enrichedInstruction += '\n\n【制約】このタスクはサブタスクとして実行中のため、SpawnSubTask は使用できません。decompose ではなく dig を選択してください。';
  }
  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,
  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,
      },
    };
  }

  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 },
    };
  }

  if (result.next === 'ABORT' && result.output.includes('cancelled')) {
    logger.info(`[piece-runner] piece=${piece.name} cancelled during movement=${currentMovementName}`);
    return {
      kind: 'done',
      result: {
        status: 'cancelled',
        finalOutput: result.output,
        movementHistory,
        abortReason: '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') {
    logger.info(`[piece-runner] piece=${piece.name} WAIT_SUBTASKS`);
    return {
      kind: 'done',
      result: {
        status: 'waiting_subtasks',
        finalOutput: result.output,
        movementHistory,
        resumeMovement: movementDef.default_next ?? null,
        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 === 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(workspacePath: string, pieceName: string, movement: string, lessons: string): void {
  try {
    const logsDir = join(workspacePath, 'logs');
    const logPath = join(logsDir, '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 = /^\.|^memory-(handoff|delta)\.json$|^absorbed-deltas\.json$/;

/**
 * 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, non-engine-internal 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');
}

/**
 * workspace/logs/checklists/ にあるチェックシートを読み込み、
 * movement 開始時に system prompt に注入するための要約テキストを生成する。
 */
export function buildChecklistContext(workspacePath: string): string {
  const dir = join(workspacePath, 'logs', '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;
}

// --- Phase 5: subtask memory handoff (parent → child direction) ---

/**
 * Read `<workspace>/input/memory-handoff.json` if present and absorb it
 * into `memory`. Used at child piece-runner startup. Returns silently
 * when no handoff is present (= top-level run, not a subtask).
 */
function applyHandoffIfPresent(workspacePath: string, memory: WorkspaceMemory, eventLogger?: EventLogger): void {
  const handoff = readHandoffFile(workspacePath);
  if (!handoff) return;
  const crossingEntry: LineageEntry = {
    jobId: handoff.parentJobId,
    workspaceRelative: handoff.parentWorkspaceRelative,
    // The parent's piece is still in flight when it spawns this subtask;
    // we record 'success' as a provisional status. (If the parent later
    // aborts, that's a parent-side concern and doesn't retroactively
    // taint already-spawned children.)
    status: 'success',
    deltaId: handoff.handoffId,
  };
  const result = memory.applyHandoff({
    facts: handoff.facts,
    decisions: handoff.decisions,
    openQuestions: handoff.openQuestions,
    doNotRepeat: handoff.doNotRepeat,
    crossingEntry,
    sourceMovement: 'inherited:handoff',
  });
  logger.info(`[piece-runner] absorbed handoff handoffId=${handoff.handoffId} parentJobId=${handoff.parentJobId} facts=${result.factsAdded} decisions=${result.decisionsAdded} openQuestions=${result.openQuestionsAdded} doNotRepeat=${result.doNotRepeatAdded}`);
  if (handoff.truncated) {
    logger.warn(`[piece-runner] handoff was truncated by sender: ${JSON.stringify(handoff.truncated)}`);
  }
  if (handoff.filteredSensitive) {
    logger.warn(`[piece-runner] handoff had ${handoff.filteredSensitive.facts + handoff.filteredSensitive.decisions} entries filtered as sensitive by sender`);
  }
  eventLogger?.emit('memory_handoff_read', {
    handoffId: handoff.handoffId,
    parentJobId: handoff.parentJobId,
    counts: result,
    truncatedBySender: handoff.truncated,
    filteredSensitiveBySender: handoff.filteredSensitive,
  });
}

/**
 * Wrap the worker's spawnSubTask closure so that after a child workspace
 * is created, we serialize the parent's current memory snapshot to
 * `<child-workspace>/input/memory-handoff.json`. The wrapper falls back
 * to the un-wrapped behavior (no handoff) when `parentJobId` is absent —
 * required for unit tests that don't set it.
 *
 * Errors during handoff write are caught and logged: a corrupted /
 * unwritable filesystem must not abort the spawn that the LLM just
 * requested. The child still runs; it just doesn't see inherited
 * memory.
 */
function wrapSpawnSubTaskWithHandoff(
  original: NonNullable<Parameters<typeof runPiece>[6]>['spawnSubTask'],
  workspaceMemory: WorkspaceMemory,
  parentJobId: string | undefined,
  eventLogger?: EventLogger,
): NonNullable<Parameters<typeof runPiece>[6]>['spawnSubTask'] {
  if (!original) return original;
  if (!parentJobId) return original;
  return async (params) => {
    const result = await original(params);
    try {
      const snapshot = workspaceMemory.snapshot();
      // Skip the write entirely when there's nothing to hand off — saves
      // a stat call in the child at every startup.
      const totalEntries = snapshot.facts.length + snapshot.decisions.length + snapshot.openQuestions.length + snapshot.doNotRepeat.length;
      if (totalEntries === 0) {
        eventLogger?.emit('memory_handoff_write', {
          childJobId: result.jobId,
          subtaskIndex: result.subtaskIndex,
          factsCount: 0,
          skipped: true,
          reason: 'no entries',
        });
        return result;
      }
      const handoff = buildMemoryHandoff({
        snapshot,
        parentJobId,
        parentWorkspaceRelative: '../..',
        handoffId: randomUUID(),
      });
      writeHandoffFile(result.workspacePath, handoff);
      eventLogger?.emit('memory_handoff_write', {
        handoffId: handoff.handoffId,
        childJobId: result.jobId,
        subtaskIndex: result.subtaskIndex,
        factsCount: handoff.facts.length,
        decisionsCount: handoff.decisions.length,
        openQuestionsCount: handoff.openQuestions.length,
        doNotRepeatCount: handoff.doNotRepeat.length,
        truncated: handoff.truncated,
        filteredSensitive: handoff.filteredSensitive,
      });
    } catch (err) {
      logger.warn(`[piece-runner] failed to write memory handoff to ${result.workspacePath}: ${(err as Error).message}`);
      eventLogger?.emit('memory_handoff_write', {
        childJobId: result.jobId,
        subtaskIndex: result.subtaskIndex,
        skipped: true,
        error: (err as Error).message,
      });
    }
    return result;
  };
}

// --- Phase 5 PR2: child → parent memory delta ---

const ABSORBED_DELTAS_FILE = 'logs/absorbed-deltas.json';
const ABSORBED_DELTAS_VERSION = 1 as const;

interface AbsorbedDeltasFile {
  version: typeof ABSORBED_DELTAS_VERSION;
  ids: string[];
}

const ABSORBED_DELTAS_SCHEMA: AtomicJsonSchema<AbsorbedDeltasFile> = {
  expectedVersion: ABSORBED_DELTAS_VERSION,
  validate: (parsed): string | null => {
    const obj = parsed as Record<string, unknown>;
    if (!Array.isArray(obj.ids)) return 'ids must be array';
    return null;
  },
  cast: (parsed): AbsorbedDeltasFile => parsed as AbsorbedDeltasFile,
};

/**
 * Restore the set of already-absorbed delta ids from the parent workspace
 * log so re-resume of `waiting_subtasks` doesn't re-merge child deltas.
 * On corruption: quarantine the file (preserves forensic data) and start
 * fresh — item-level dedupe via `claim`/`text` exact-match still backstops
 * any duplicate that might result.
 */
function restoreAbsorbedDeltaIds(workspacePath: string, memory: WorkspaceMemory): void {
  const path = join(workspacePath, ABSORBED_DELTAS_FILE);
  const result = readSafeJson(path, ABSORBED_DELTAS_SCHEMA);
  if (result.kind === 'missing') return;
  if (result.kind === 'corrupt') {
    const quarantined = quarantineCorruptFile(path);
    logger.warn(`[piece-runner] absorbed-deltas log corrupt at ${path} (${result.reason}); quarantined to ${quarantined ?? '(failed)'}`);
    return;
  }
  memory.restoreAbsorbedDeltaIds(result.value.ids);
  logger.info(`[piece-runner] restored ${result.value.ids.length} absorbed deltaId(s)`);
}

function persistAbsorbedDeltaIds(workspacePath: string, memory: WorkspaceMemory): void {
  const path = join(workspacePath, ABSORBED_DELTAS_FILE);
  try {
    const payload: AbsorbedDeltasFile = {
      version: ABSORBED_DELTAS_VERSION,
      ids: memory.getAbsorbedDeltaIds(),
    };
    writeAtomicJson(path, payload);
  } catch (err) {
    logger.warn(`[piece-runner] failed to persist absorbed-deltas log: ${(err as Error).message}`);
  }
}

/**
 * Scan `<workspace>/subtasks/* /output/memory-delta.json` and absorb each
 * not-yet-absorbed delta into `memory`. Idempotent: re-running this on
 * the same parent workspace performs no work after the first call.
 */
function absorbReadyChildDeltas(workspacePath: string, memory: WorkspaceMemory, eventLogger?: EventLogger): void {
  const subtasksDir = join(workspacePath, 'subtasks');
  if (!existsSync(subtasksDir)) return;

  let entries: string[];
  try {
    entries = readdirSync(subtasksDir);
  } catch (err) {
    logger.warn(`[piece-runner] failed to scan subtasks dir ${subtasksDir}: ${(err as Error).message}`);
    return;
  }

  let absorbedThisPass = 0;
  for (const subtaskName of entries.sort()) {
    const subtaskWorkspace = join(subtasksDir, subtaskName);
    const delta = readDeltaFile(subtaskWorkspace);
    if (!delta) continue;
    if (memory.hasAbsorbedDelta(delta.deltaId)) {
      eventLogger?.emit('memory_delta_absorb', {
        deltaId: delta.deltaId,
        childJobId: delta.childJobId,
        childWorkspaceRelative: delta.childWorkspaceRelative,
        childStatus: delta.childStatus,
        partial: delta.partial,
        outcome: 'skipped_already_absorbed',
      });
      continue;
    }

    // Don't absorb aborted children's deltas unless they explicitly opted
    // in via the `partial` flag (set by the child piece-runner only when
    // the aborted piece called memory_update — Codex policy).
    if (delta.childStatus === 'aborted' && !delta.partial) {
      logger.info(`[piece-runner] skipping aborted-without-partial delta deltaId=${delta.deltaId} childJobId=${delta.childJobId}`);
      memory.markDeltaAbsorbed(delta.deltaId); // mark so we don't re-check next run
      eventLogger?.emit('memory_delta_absorb', {
        deltaId: delta.deltaId,
        childJobId: delta.childJobId,
        childWorkspaceRelative: delta.childWorkspaceRelative,
        childStatus: delta.childStatus,
        partial: delta.partial,
        outcome: 'skipped_aborted_without_partial',
      });
      continue;
    }

    // Path rewriter: child-relative → parent-relative, normalize-aware.
    const rewritePath = (childPath: string): string =>
      prefixWorkspacePath(delta.childWorkspaceRelative, childPath);

    const crossing: LineageEntry = {
      jobId: delta.childJobId,
      workspaceRelative: delta.childWorkspaceRelative,
      status: delta.childStatus,
      deltaId: delta.deltaId,
    };

    const result = memory.absorbDelta({
      deltaId: delta.deltaId,
      facts: delta.facts,
      decisions: delta.decisions,
      openQuestions: delta.openQuestions,
      doNotRepeat: delta.doNotRepeat,
      crossingEntry: crossing,
      rewritePath,
      sourceMovement: 'inherited:delta',
    });

    if (result.kind === 'merged') {
      absorbedThisPass++;
      logger.info(`[piece-runner] absorbed delta deltaId=${delta.deltaId} childJobId=${delta.childJobId} status=${delta.childStatus} factsAdded=${result.counts.factsAdded} factsMerged=${result.counts.factsMerged} decisionsAdded=${result.counts.decisionsAdded} pathsDropped=${result.counts.pathsDropped}`);
      eventLogger?.emit('memory_delta_absorb', {
        deltaId: delta.deltaId,
        childJobId: delta.childJobId,
        childWorkspaceRelative: delta.childWorkspaceRelative,
        childStatus: delta.childStatus,
        partial: delta.partial,
        outcome: 'merged',
        counts: result.counts,
      });
    } else {
      eventLogger?.emit('memory_delta_absorb', {
        deltaId: delta.deltaId,
        childJobId: delta.childJobId,
        outcome: 'skipped_already_absorbed',
        reason: result.reason,
      });
    }
  }

  if (absorbedThisPass > 0) {
    persistAbsorbedDeltaIds(workspacePath, memory);
  }
}

/**
 * If this run IS a subtask (parentJobId + childWorkspaceRelative provided),
 * write the child → parent memory delta to `output/memory-delta.json` so
 * the parent can absorb it on resume. Status governs whether the file is
 * written and what `partial` flag is set:
 *
 *   completed       → write, partial=false
 *   waiting_human   → write, partial=false (resumed parent gets the
 *                     observations; questions sit in openQuestions)
 *   waiting_subtasks→ skip (this child is mid-flight, not done)
 *   aborted         → write only if memory has post-handoff content
 *                     beyond inherited facts (=> the piece called
 *                     memory_update); marked partial=true
 *   error/cancelled → skip (untrustworthy)
 */
function writeSubtaskDeltaIfChild(
  result: PieceRunResult,
  workspacePath: string,
  workspaceMemory: WorkspaceMemory,
  options: { parentJobId?: string; childWorkspaceRelative?: string; jobId?: string } | undefined,
  eventLogger?: EventLogger,
): void {
  if (!options?.parentJobId || !options.childWorkspaceRelative || !options.jobId) return;

  const status = result.status;
  let childStatus: ChildPieceStatus;
  let partial = false;
  if (status === 'completed') {
    childStatus = 'success';
  } else if (status === 'waiting_human') {
    childStatus = 'needs_user_input';
  } else if (status === 'aborted') {
    const snap = workspaceMemory.snapshot();
    const fresh = snap.facts.filter((f) => !f.lineage.some((l) => l.jobId === options.parentJobId));
    const freshDecisions = snap.decisions.filter((d) => !d.lineage.some((l) => l.jobId === options.parentJobId));
    if (fresh.length === 0 && freshDecisions.length === 0 && snap.openQuestions.length === 0 && snap.doNotRepeat.length === 0) {
      eventLogger?.emit('memory_delta_write', { skipped: true, reason: 'aborted without new memory' });
      return;
    }
    childStatus = 'aborted';
    partial = true;
  } else {
    eventLogger?.emit('memory_delta_write', { skipped: true, reason: `status=${status}` });
    return;
  }

  try {
    const delta = buildMemoryDelta({
      snapshot: workspaceMemory.snapshot(),
      childJobId: options.jobId,
      childWorkspaceRelative: options.childWorkspaceRelative,
      childStatus,
      partial,
      deltaId: randomUUID(),
      parentJobId: options.parentJobId,
    });
    if (
      delta.facts.length === 0 &&
      delta.decisions.length === 0 &&
      delta.openQuestions.length === 0 &&
      delta.doNotRepeat.length === 0
    ) {
      eventLogger?.emit('memory_delta_write', { skipped: true, reason: 'nothing genuinely new' });
      return;
    }
    writeDeltaFile(workspacePath, delta);
    eventLogger?.emit('memory_delta_write', {
      deltaId: delta.deltaId,
      childStatus,
      partial,
      factsCount: delta.facts.length,
      decisionsCount: delta.decisions.length,
      openQuestionsCount: delta.openQuestions.length,
      doNotRepeatCount: delta.doNotRepeat.length,
      truncated: delta.truncated,
    });
  } catch (err) {
    logger.warn(`[piece-runner] failed to write memory delta: ${(err as Error).message}`);
    eventLogger?.emit('memory_delta_write', { skipped: true, reason: 'write failed', error: (err as Error).message });
  }
}

/**
 * Cancel-traceability PR1: persist an in-memory `WorkspaceMemory` 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;
  facts: number;
  decisions: number;
  openQuestions: number;
  doNotRepeat: 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,
  workspaceMemory: WorkspaceMemory,
  lessonsAccumulator: Array<{ movement: string; lessons: string }>,
  totalSteps: number,
  eventLogger: EventLogger,
): MemorySnapshotMeta | null {
  if (!workspacePath) return null;
  if (!SNAPSHOT_TRIGGER_STATUSES.has(result.status)) return null;

  const snapshot = workspaceMemory.snapshot();
  const writtenAt = new Date().toISOString();
  const tsCompact = writtenAt.replace(/[:.]/g, '').replace('T', 'T').replace(/Z$/, 'Z');
  const relPath = `logs/memory-snapshot-${result.status}-${runId}-${tsCompact}.json`;
  const absPath = 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',
    memory: snapshot,
  };

  try {
    writeAtomicJson(absPath, fileContent);
    const bytes = Buffer.byteLength(JSON.stringify(fileContent), 'utf-8');
    const meta: MemorySnapshotMeta = {
      pathRelative: relPath,
      bytes,
      facts: snapshot.facts.length,
      decisions: snapshot.decisions.length,
      openQuestions: snapshot.openQuestions.length,
      doNotRepeat: snapshot.doNotRepeat.length,
      movements: movementSummary.length,
      lessons: lessonsSummary.length,
    };
    eventLogger.emit('memory_snapshot_written', {
      status: result.status,
      path: relPath,
      bytes,
      facts: meta.facts,
      decisions: meta.decisions,
      openQuestions: meta.openQuestions,
      doNotRepeat: meta.doNotRepeat,
      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;
}
