name: canonworks-serial-episode-factory
version: '1.0'
description: CanonWorks generic serial episode factory. This preserves the working example episode workflow's top-level step
  contract and adds only project-config normalization so the same pipeline can run against any Kumiho canon project.
inputs:
- name: project_config_yaml
  type: string
  required: true
  description: CanonWorks project config YAML/JSON, or a file path generated by canonworks_commit. Operators should normally
    use canonworks_run_episode, which passes the stored config path automatically.
- name: target_length
  required: false
  default: 6000자
  description: 이번 회차 목표 분량.
- name: episode_goal
  required: false
  description: 이번 실행에서 특별히 달성하고 싶은 회차 목표. 비우면 active storyline과 최신 회차 hook 기반으로 자동 결정.
- name: must_include
  required: false
  description: 이번 회차에 반드시 포함해야 하는 사건/인물/대사/떡밥.
- name: avoid
  required: false
  description: 이번 회차에서 피해야 할 전개, 인물, 소재, 금지사항.
- name: continuity_context
  required: false
  description: 사용자가 직접 제공하는 추가 연속성 맥락.
- name: pacing_mode
  required: false
  default: balanced
  description: 회차 리듬. balanced, fast, emotional, technical, comedic 등.
- name: opencrab_query
  required: false
  description: 외부 검증/레퍼런스가 필요한 경우 OpenCrab에 던질 추가 질의.
- name: initial_episode_number
  required: false
  default: 1
  description: production-ready 회차가 아직 없을 때 시작할 회차 번호.
- name: initial_volume
  required: false
  default: 1
  description: production-ready 회차가 아직 없을 때 시작할 권 번호.
- name: include_relationship_layers
  required: false
  default: primary,operational,thematic
  description: 포함할 관계 edge layer. relation_layer가 없으면 primary로 간주.
- name: min_relationship_strength
  required: false
  default: medium
  description: 확장 관계 edge의 최소 강도. primary edge는 항상 허용.
steps:
- id: project-config
  name: canonworks_project_config
  type: python
  position:
    x: -1240
    y: -620
  description: Parse and normalize CanonWorks project_config_yaml for all project-specific values.
  python:
    code: |
      import json
      import re
      import sys
      from pathlib import Path

      try:
          import yaml
      except Exception:
          yaml = None

      raw_payload = sys.stdin.buffer.read().decode('utf-8', errors='replace')
      payload = json.loads(raw_payload or '{}')
      args = payload.get('args') or {}
      context_inputs = ((payload.get('context') or {}).get('inputs') or {})
      raw = str(args.get('project_config_yaml') or context_inputs.get('project_config_yaml') or '').strip()

      if raw and len(raw) < 500 and ('\n' not in raw) and Path(raw).expanduser().exists():
          raw = Path(raw).expanduser().read_text(encoding='utf-8')
      if not raw:
          raise SystemExit('project_config_yaml is required')

      data = yaml.safe_load(raw) if yaml else json.loads(raw)
      if not isinstance(data, dict):
          raise SystemExit('project_config_yaml must parse to a mapping')
      cfg = data.get('canon_project') or data.get('story_project') or data
      if not isinstance(cfg, dict):
          raise SystemExit('canon_project must be a mapping')

      def first(*values):
          for value in values:
              if value is not None and str(value).strip():
                  return str(value).strip()
          return ''

      def slugify(value):
          value = re.sub(r'[^A-Za-z0-9가-힣_-]+', '-', str(value).strip()).strip('-')
          return value.lower() or 'canonworks-project'

      project_id = first(cfg.get('project'), cfg.get('project_id'), cfg.get('kumiho_project'))
      title = first(cfg.get('title'), cfg.get('series_title'), cfg.get('name'))
      story_slug = first(cfg.get('id'), cfg.get('slug'), slugify(title))
      if not project_id:
          raise SystemExit('canon_project.project is required')
      if not title:
          raise SystemExit('canon_project.title is required')

      spaces = cfg.get('spaces') if isinstance(cfg.get('spaces'), dict) else {}
      bundles = cfg.get('bundles') if isinstance(cfg.get('bundles'), dict) else {}
      krefs = cfg.get('krefs') if isinstance(cfg.get('krefs'), dict) else {}
      naming = cfg.get('naming') if isinstance(cfg.get('naming'), dict) else {}

      def space(key, default_name):
          value = first(spaces.get(key), default_name)
          if '/' in value or value.startswith(project_id):
              return value.strip('/')
          return f'{project_id}/{value.strip("/")}'

      def bundle(key, default_suffix):
          return first(bundles.get(key), f'{story_slug}-{default_suffix}')

      def kref(key, default_path):
          return first(krefs.get(key), f'kref://{project_id}/{default_path}')

      def as_yamlish(value):
          if value in (None, ''):
              return ''
          try:
              if yaml:
                  return yaml.safe_dump(value, allow_unicode=True, sort_keys=False).strip()
          except Exception:
              pass
          return json.dumps(value, ensure_ascii=False)

      char_snapshot = first(naming.get('character_state_snapshot_name'), 'current-character-state-snapshot')
      rel_snapshot = first(naming.get('relationship_state_snapshot_name'), 'current-relationship-state-snapshot')
      timeline_snapshot = first(naming.get('timeline_progress_snapshot_name'), 'current-timeline-progress-snapshot')
      storyline_snapshot = first(naming.get('storyline_progress_snapshot_name'), 'current-storyline-progress-snapshot')
      foreshadow_snapshot = first(naming.get('foreshadow_progress_snapshot_name'), 'current-foreshadow-progress-snapshot')
      episode_name_prefix = first(naming.get('episode_name_prefix'), 'ep')
      episode_id_prefix = first(naming.get('episode_id_prefix'), 'ep')
      volume_bundle_prefix = first(naming.get('volume_bundle_prefix'), f'{story_slug}-vol')

      out = {
          'project_id': project_id,
          'story_slug': story_slug,
          'title': title,
          'language': first(cfg.get('language'), 'ko-KR'),
          'cadence': first(cfg.get('cadence'), 'web_serial'),
          'default_episode_length_chars': first(cfg.get('default_episode_length_chars'), '6000'),
          'episode_kind': first(cfg.get('episode_kind'), 'webnovel-episode'),
          'patch_kind': first(cfg.get('patch_kind'), 'canon-patch'),
          'context_pack_kind': first(cfg.get('context_pack_kind'), 'context-pack'),
          'episode_name_prefix': episode_name_prefix,
          'episode_id_prefix': episode_id_prefix,
          'context_pack_suffix': first(naming.get('context_pack_suffix'), 'context'),
          'patch_name_suffix': first(naming.get('patch_name_suffix'), 'canon-patch'),
          'blocked_name_suffix': first(naming.get('blocked_name_suffix'), 'blocked'),
          'volume_bundle_prefix': volume_bundle_prefix,
          'episode_space': space('episodes', 'Episodes'),
          'patch_space': space('patches', 'Patches'),
          'context_pack_space': space('context_packs', 'ContextPacks'),
          'state_space': space('state', 'State'),
          'progress_space': space('progress', 'Progress'),
          'report_space': space('reports', 'Reports'),
          'main_canon_bundle': bundle('main_canon', 'main-canon'),
          'production_style_bundle': bundle('production_style', 'production-style'),
          'production_episodes_bundle': bundle('production_episodes', 'production-episodes'),
          'canon_patch_candidates_bundle': bundle('canon_patch_candidates', 'canon-patch-candidates'),
          'current_character_states_bundle': bundle('current_character_states', 'current-character-states'),
          'current_relationship_states_bundle': bundle('current_relationship_states', 'current-relationship-states'),
          'current_timeline_progress_bundle': bundle('current_timeline_progress', 'current-timeline-progress'),
          'current_storyline_progress_bundle': bundle('current_storyline_progress', 'current-storyline-progress'),
          'current_foreshadow_progress_bundle': bundle('current_foreshadow_progress', 'current-foreshadow-progress'),
          'state_sync_snapshots_bundle': bundle('state_sync_snapshots', 'state-sync-snapshots'),
          'canon_state_sync_reports_bundle': bundle('canon_state_sync_reports', 'canon-state-sync-reports'),
          'active_storylines_bundle': bundle('active_storylines', 'active-storylines'),
          'active_foreshadow_bundle': bundle('active_foreshadow', 'active-foreshadow'),
          'context_packs_bundle': bundle('context_packs', 'context-packs'),
          'blocked_episodes_bundle': bundle('blocked_episodes', 'blocked-episodes'),
          'series_bible_kref': kref('series_bible', 'Series/main.series-bible'),
          'series_synopsis_kref': kref('series_synopsis', 'Series/canon-synopsis.series-synopsis'),
          'character_index_kref': kref('character_index', 'Characters/index.character-index'),
          'relationship_map_kref': kref('relationship_map', 'Relationships/main.relationship-map'),
          'relationship_map_artifact_kref': first(
              krefs.get('relationship_map_artifact'),
              f'kref://{project_id}/Relationships/main.relationship-map?r=1&a=RELATIONSHIP_MAP.md',
          ),
          'timeline_kref': kref('timeline', 'Timeline/main.timeline'),
          'roadmap_kref': kref('roadmap', 'Roadmaps/long-arc.series-roadmap'),
          'current_character_state_kref': kref('current_character_state', f'State/{char_snapshot}.character-state'),
          'current_relationship_state_kref': kref('current_relationship_state', f'State/{rel_snapshot}.relationship-state'),
          'current_timeline_progress_kref': kref('current_timeline_progress', f'Progress/{timeline_snapshot}.timeline-progress'),
          'current_storyline_progress_kref': kref('current_storyline_progress', f'Progress/{storyline_snapshot}.storyline-progress'),
          'current_foreshadow_progress_kref': kref('current_foreshadow_progress', f'Progress/{foreshadow_snapshot}.foreshadow-progress'),
          'character_state_snapshot_name': char_snapshot,
          'relationship_state_snapshot_name': rel_snapshot,
          'timeline_progress_snapshot_name': timeline_snapshot,
          'storyline_progress_snapshot_name': storyline_snapshot,
          'foreshadow_progress_snapshot_name': foreshadow_snapshot,
          'genre_modules_text': ', '.join(str(x) for x in (cfg.get('genre_modules') or [])),
          'agent_personas_text': as_yamlish(cfg.get('agent_personas') or {}),
          'priority_rules_text': as_yamlish(cfg.get('priority_rules') or []),
          'audit_rules_text': as_yamlish(cfg.get('audit_rules') or []),
          'external_reference_seed': first(
              cfg.get('external_reference_seed'),
              cfg.get('reference_seed'),
              '작품 장르, 시대, 직업, 문화, 플랫폼 맥락에 맞는 외부 레퍼런스',
          ),
      }
      out['config_summary'] = json.dumps({
          'project': out['project_id'],
          'story_slug': out['story_slug'],
          'title': out['title'],
          'language': out['language'],
          'episode_space': out['episode_space'],
          'patch_space': out['patch_space'],
          'main_canon_bundle': out['main_canon_bundle'],
          'production_episodes_bundle': out['production_episodes_bundle'],
      }, ensure_ascii=False)
      sys.stdout.buffer.write(json.dumps(out, ensure_ascii=False).encode('utf-8'))
    args:
      project_config_yaml: ${inputs.project_config_yaml}
    timeout: 30
    allow_failure: false
- id: latest-production-episode
  name: latest_production_episode
  type: resolve
  position:
    x: -920
    y: -420
  description: 최신 production-ready 회차를 불러온다. 없으면 initial_episode_number로 시작한다.
  retry: 1
  resolve:
    kind: ${project-config.output_data.episode_kind}
    tag: production-ready
    name_pattern: ''
    space: ${project-config.output_data.episode_space}
    mode: latest
    fields:
    - episode_number
    - volume
    - title
    - storyline_ids
    - foreshadow_ids
    - next_hook
    - summary
    fail_if_missing: false
- id: next-episode-info
  name: next_episode_info
  type: compute
  position:
    x: -624
    y: -420
  description: 다음 회차 번호, 권 번호, 엔티티 이름, 권별 번들 이름을 계산한다.
  retry: 1
  compute:
    outputs:
      previous_episode_found: ${{ latest_production_episode.output_data.found }}
      previous_episode_number: ${{ int(latest_production_episode.output_data.episode_number) if latest_production_episode.output_data.found
        else 0 }}
      episode_number: ${{ ((int(latest_production_episode.output_data.episode_number) + 1) if latest_production_episode.output_data.found
        else int(inputs.initial_episode_number)) }}
      volume: ${{ (int(latest_production_episode.output_data.volume) if latest_production_episode.output_data.found else int(inputs.initial_volume))
        }}
      volume_02: ${{ str((int(latest_production_episode.output_data.volume) if latest_production_episode.output_data.found
        else int(inputs.initial_volume))).zfill(2) }}
      episode_id: ${{ project_config.output_data.episode_id_prefix + '-' + str(((int(latest_production_episode.output_data.episode_number)
        + 1) if latest_production_episode.output_data.found else int(inputs.initial_episode_number))).zfill(3) }}
      entity_name: ${{ project_config.output_data.episode_name_prefix + '-' + str(((int(latest_production_episode.output_data.episode_number)
        + 1) if latest_production_episode.output_data.found else int(inputs.initial_episode_number))).zfill(3) }}
      context_pack_name: ${{ outputs.entity_name + '-' + project_config.output_data.context_pack_suffix }}
      patch_name: ${{ outputs.entity_name + '-' + project_config.output_data.patch_name_suffix }}
      blocked_name: ${{ outputs.entity_name + '-' + project_config.output_data.blocked_name_suffix }}
      volume_bundle: ${{ project_config.output_data.volume_bundle_prefix + '-' + str((int(latest_production_episode.output_data.volume)
        if latest_production_episode.output_data.found else int(inputs.initial_volume))).zfill(2) }}
      volume_episode_bundle: ${{ outputs.volume_bundle + '-episodes' }}
      previous_hook: ${{ latest_production_episode.output_data.next_hook if latest_production_episode.output_data.found else
        '' }}
  depends_on:
  - latest-production-episode
- id: episode-context
  name: episode_context
  type: kumiho_context
  position:
    x: -320
    y: -420
  description: Kumiho Bundle, 최신 회차, semantic query, relationship edge traversal을 사용해 이번 회차용 locked context pack을 만든다.
  retry: 1
  kumiho:
    project: ${project-config.output_data.project_id}
    mode: graph_augmented_context
    seed:
      bundles:
      - ${project-config.output_data.main_canon_bundle}
      - ${project-config.output_data.production_style_bundle}
      - ${next-episode-info.output_data.volume_bundle}
      - ${project-config.output_data.current_character_states_bundle}
      - ${project-config.output_data.current_relationship_states_bundle}
      - ${project-config.output_data.current_timeline_progress_bundle}
      - ${project-config.output_data.current_storyline_progress_bundle}
      - ${project-config.output_data.current_foreshadow_progress_bundle}
      - ${project-config.output_data.state_sync_snapshots_bundle}
      - ${project-config.output_data.canon_state_sync_reports_bundle}
      - ${project-config.output_data.active_storylines_bundle}
      - ${project-config.output_data.active_foreshadow_bundle}
      - ${project-config.output_data.production_episodes_bundle}
      - ${next-episode-info.output_data.volume_episode_bundle}
      krefs:
      - ${latest_production_episode.output_data.kref}
      - ${project-config.output_data.series_bible_kref}
      - ${project-config.output_data.series_synopsis_kref}
      - ${project-config.output_data.character_index_kref}
      - ${project-config.output_data.current_character_state_kref}
      - ${project-config.output_data.current_relationship_state_kref}
      - ${project-config.output_data.current_timeline_progress_kref}
      - ${project-config.output_data.current_storyline_progress_kref}
      - ${project-config.output_data.current_foreshadow_progress_kref}
      - ${project-config.output_data.relationship_map_kref}
      - ${project-config.output_data.relationship_map_artifact_kref}
      - ${project-config.output_data.timeline_kref}
      - ${project-config.output_data.roadmap_kref}
      queries:
      - <<${project-config.output_data.title}>> 다음 회차 ${next-episode-info.output_data.episode_number} 생산
      - post-episode canon state sync current snapshots for next episode production
      - current story clock / 극중 시간 / scene temporal anchor continuity from latest production episode
      - 현재 권 번들 ${next-episode-info.output_data.volume_bundle} 기준 회차 기능과 금지된 미래 스포일러
      - relationship map primary operational thematic edges scene pressure
      - ${inputs.episode_goal}
      - ${inputs.must_include}
      - ${inputs.continuity_context}
      - ${inputs.avoid}
    traversal:
      max_depth: 2
      direction: both
      edge_types:
      - DEPENDS_ON
      - DERIVED_FROM
      - REFERENCES
      - REFERENCED
      - ADVANCES
      - FORESHADOWS
      - PAYOFF_TARGET
      - UPDATES
      - CONTRADICTS
      - BLOCKS
      - RELATED_TO
    filters:
      include_kinds:
      - series-bible
      - series-synopsis
      - style-guide
      - canon-rule
      - character-index
      - character
      - character-state
      - relationship-map
      - relationship-state
      - timeline
      - timeline-event
      - timeline-progress
      - storyline
      - storyline-progress
      - plot-thread
      - foreshadow-thread
      - foreshadow-progress
      - volume-plan
      - series-roadmap
      - arc-blueprint
      - webnovel-episode
      - canon-patch
      - canon-state-sync-report
      - continuity-audit
      - pacing-audit
      exclude_tags:
      - deprecated
      - rejected
      max_items: 80
      max_revisions_per_item: 1
    ranking:
      method: hybrid
      semantic_query: ${inputs.episode_goal} ${inputs.must_include} ${inputs.continuity_context} ${inputs.avoid} relationship
        graph ${next-episode-info.output_data.volume_bundle}
      prefer_recent: true
      prefer_current_tags: true
      boost_kinds:
        canon-rule: 2.2
        style-guide: 1.8
        relationship-map: 2
        relationship-state: 2
        character-state: 1.8
        character: 1.6
        volume-plan: 1.8
        timeline: 2.2
        timeline-event: 2.2
        timeline-progress: 2.6
        storyline: 1.6
        storyline-progress: 1.8
        foreshadow-thread: 1.5
        foreshadow-progress: 1.8
        webnovel-episode: 1.4
      boost_edge_types:
        CONTRADICTS: 2.5
        BLOCKS: 2.2
        REFERENCED: 1.8
        PAYOFF_TARGET: 1.6
        ADVANCES: 1.4
    lock:
      revisions: true
      tag_preference:
      - current
      - active
      - production-ready
      - ready
      - published
      - latest
    output:
      format: episode_context_pack
      include_artifact_summaries: true
      include_artifact_content: false
      max_artifact_chars_per_item: 3500
      max_total_artifact_chars: 90000
      include_edge_map: true
      include_conflict_warnings: true
      include_missing_context: true
      include_source_krefs: true
  depends_on:
  - latest-production-episode
  - next-episode-info
- id: volume-canon-alignment
  name: volume_canon_alignment
  type: agent
  position:
    x: 80
    y: -420
  description: 현재 권 bundle + roadmap + timeline으로 이번 회차가 진행할 수 있는 캐논 범위를 정의한다.
  retry: 1
  agent:
    agent_type: codex
    role: reviewer
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} volume canon alignment.

      너는 Volume Canon Planner다. 원고를 쓰지 않는다.
      현재 회차가 현재 권에서 무엇을 진행할 수 있고 무엇을 아직 공개하면 안 되는지 정의한다.

      EPISODE: ${next-episode-info.output_data.episode_number}
      VOLUME: ${next-episode-info.output_data.volume}
      VOLUME_BUNDLE: ${next-episode-info.output_data.volume_bundle}

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      입력:
      - episode_goal: ${inputs.episode_goal}
      - must_include: ${inputs.must_include}
      - avoid: ${inputs.avoid}
      - continuity_context: ${inputs.continuity_context}
      - pacing_mode: ${inputs.pacing_mode}

      원칙:
      - Kumiho canon이 최상위 진실이다.
      - 현재 권 번들, 200ep roadmap, main timeline을 우선한다.
      - current-timeline-progress-snapshot의 극중 시간(current_story_time/current_clock/scene_time_anchor/elapsed_time)과 직전 장면 anchor(location/cast/active_event)를 추출한다.
      - 같은 장소 + 같은 주요 등장인물 + 같은 active_event가 이어지는 연속 장면이면 시간 연속성은 hard constraint다.
      - 다른 장소/다른 인물의 병렬 장면, 명시적 시간 경과, 플래시백/플래시포워드, 장면 전환이 분명한 경우에는 시간 이동을 허용할 수 있다.
      - 허용되는 시간 이동도 본문에 독자가 알 수 있는 temporal marker를 반드시 요구한다.
      - 무표식 시간 점프, 특히 같은 장소/같은 인물/같은 사건에서의 시간 변경은 conflict_warnings에 반드시 기록한다.
      - 후반 권 스포일러를 앞당기지 않는다.
      - style-guide와 canon-rule 중 이번 회차에 직접 관련되는 룰을 뽑는다.

      반드시 FINAL_OUTPUT YAML만 출력하라.

      FINAL_OUTPUT:
      volume_alignment:
        episode_number: ${next-episode-info.output_data.episode_number}
        volume: ${next-episode-info.output_data.volume}
        volume_bundle: "${next-episode-info.output_data.volume_bundle}"
        volume_promise: ""
        current_episode_function: "setup|payoff|reversal|bridge|escalation|aftermath"
        required_canon_sources:
          - revision_kref: ""
            reason: ""
        temporal_continuity:
          current_story_time: ""
          previous_episode_end_time: ""
          previous_scene_location: ""
          previous_scene_cast: []
          active_event: ""
          next_episode_start_time: ""
          elapsed_time_since_previous_episode: ""
          required_scene_time_anchor: ""
          time_shift_policy: "same_scene_continuity|required_marker|parallel_scene|flashback|explicit_elapsed_time"
          allowed_time_shifts:
            - kind: "parallel_scene|explicit_elapsed_time|flashback|new_location_new_cast"
              condition: ""
              required_marker: ""
          forbidden_time_jumps:
            - "unmarked time jump with same location, same cast, and same active event"
          source_krefs:
            - ""
        relevant_timeline_events:
          - kref: ""
            relevance: ""
        allowed_future_knowledge:
          - ""
        forbidden_future_leaks:
          - ""
        required_style_rules:
          - kref: ""
            rule: ""
        conflict_warnings:
          - ""
    timeout: 1200
    max_turns: 20
  assign: originality-ip-reviewer
  depends_on:
  - episode-context
- id: relationship-pressure-plan
  name: relationship_pressure_plan
  type: agent
  position:
    x: 460
    y: -420
  description: 관계 그래프를 이번 회차 장면 압력선으로 변환한다.
  retry: 1
  agent:
    agent_type: codex
    role: reviewer
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} relationship pressure plan.

      너는 Relationship Director다. 원고를 쓰지 않는다.
      관계도 edge를 장면 압력선으로 변환한다.

      REQUIRED SOURCES:
      - Relationship map: ${project-config.output_data.relationship_map_artifact_kref}
      - Character index: ${project-config.output_data.character_index_kref}
      - Current volume bundle: ${next-episode-info.output_data.volume_bundle}

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      입력:
      - include_relationship_layers: ${inputs.include_relationship_layers}
      - min_relationship_strength: ${inputs.min_relationship_strength}
      - episode_goal: ${inputs.episode_goal}
      - must_include: ${inputs.must_include}

      Graph policy:
      - main.relationship-map의 primary edge를 우선한다.
      - operational/thematic 확장 edge는 장면에 실제 압력을 줄 때만 쓴다.
      - 모두를 모두에게 연결하지 않는다.
      - 장면에서 쓰는 관계는 기존 edge를 인용하거나 canon patch candidate로 보낸다.
      - P2/inactive 인물은 현재 권, 이전 hook, 사용자 명시 입력, 강한 graph pressure가 있을 때만 쓴다.

      반드시 FINAL_OUTPUT YAML만 출력하라.

      FINAL_OUTPUT:
      relationship_pressure_plan:
        cast_shortlist:
          - character_kref: ""
            reason: ""
            required: true
        active_relationship_edges:
          - source_kref: ""
            target_kref: ""
            relation_label: ""
            relation_kind: ""
            relation_layer: "primary|operational|thematic"
            relation_strength: "high|medium|low"
            scene_pressure: ""
            source_document: "${project-config.output_data.relationship_map_artifact_kref}"
        pressure_map:
          protagonist_pressure: []
          romance_pressure: []
          business_pressure: []
          ethics_pressure: []
          comedy_pressure: []
        dialogue_constraints:
          - character: ""
            must_sound_like: ""
            must_not_sound_like: ""
        forbidden_relationship_moves:
          - rule: ""
            reason: ""
        allowed_relationship_delta_candidates:
          - source: ""
            target: ""
            possible_delta: ""
            must_be_patch_candidate: true
    timeout: 1200
    max_turns: 20
  assign: continuity-lorekeeper
  depends_on:
  - episode-context
  - volume-canon-alignment
- id: opencrab-reference-builder
  name: opencrab_reference_builder
  type: python
  position:
    x: -320
    y: -120
  description: OpenCrab MCP HTTP endpoint에 직접 JSON-RPC tools/call을 날려 외부 레퍼런스를 수집한다. agent sidecar의 empty output/tool injection
    문제를 우회한다.
  retry: 1
  python:
    code: |
      import json
      import os
      import sys
      import urllib.request
      import urllib.error
      from pathlib import Path

      try:
          import tomllib
      except Exception:
          tomllib = None

        raw_payload = sys.stdin.buffer.read().decode('utf-8', errors='replace')
        payload = json.loads(raw_payload or '{}')
      args = payload.get('args', {})

      def load_opencrab_url():
          config_path = Path.home() / '.revka' / 'config.toml'
          if tomllib and config_path.exists():
              try:
                  data = tomllib.loads(config_path.read_text(encoding='utf-8'))
                  for srv in ((data.get('mcp') or {}).get('servers') or []):
                      if str(srv.get('name', '')).lower() == 'opencrab':
                          url = str(srv.get('url', '')).strip()
                          if url:
                              return url
              except Exception:
                  pass
          return 'https://opencrab.vercel.app/api/mcp/ocm__JRQtx3gaXpVX1xtoWcHKc9fFTRsMfGwpi9AUUmGVo0'

      OPENCRAB_URL = load_opencrab_url()

      def rpc_call(tool_name, arguments, call_id):
          body = json.dumps({
              'jsonrpc': '2.0',
              'id': call_id,
              'method': 'tools/call',
              'params': {
                  'name': tool_name,
                  'arguments': arguments,
              },
          }).encode('utf-8')
          req = urllib.request.Request(
              OPENCRAB_URL,
              data=body,
              method='POST',
              headers={
                  'Content-Type': 'application/json',
                  'Accept': 'application/json, text/event-stream',
              },
          )
          try:
              with urllib.request.urlopen(req, timeout=90) as resp:
                  raw = resp.read().decode('utf-8', errors='replace')
              parsed = json.loads(raw)
              if parsed.get('error'):
                  return {'ok': False, 'error': parsed.get('error'), 'raw': raw[:2000]}
              result = parsed.get('result') or {}
              text_parts = []
              for item in result.get('content') or []:
                  if isinstance(item, dict) and item.get('text'):
                      text_parts.append(str(item.get('text')))
              inner_text = '\n'.join(text_parts).strip()
              inner = None
              if inner_text:
                  try:
                      inner = json.loads(inner_text)
                  except Exception:
                      inner = {'text': inner_text}
              return {'ok': True, 'inner': inner, 'raw': raw[:4000]}
          except Exception as exc:
              return {'ok': False, 'error': str(exc)}

      def compact_evidence(inner, limit=3):
          if not isinstance(inner, dict):
              return []
          out = []
          for ev in inner.get('evidence') or inner.get('results') or []:
              if not isinstance(ev, dict):
                  continue
              meta = ev.get('metadata') if isinstance(ev.get('metadata'), dict) else {}
              title = meta.get('title') or meta.get('source_title') or ev.get('title') or ev.get('id') or 'OpenCrab evidence'
              text = ev.get('text') or ev.get('content') or ev.get('chunk') or meta.get('text') or meta.get('chunk') or ''
              if not text and isinstance(ev.get('document'), dict):
                  text = ev['document'].get('text') or ev['document'].get('content') or ''
              out.append({
                  'title': str(title)[:120],
                  'snippet': str(text).replace('\n', ' ')[:500],
                  'id': str(ev.get('id') or '')[:80],
              })
              if len(out) >= limit:
                  break
          return out

      base_terms = []
      for key in ('episode_goal', 'must_include', 'opencrab_query'):
          val = str(args.get(key) or '').strip()
          if val:
              base_terms.append(val)
      if not base_terms:
          base_terms.append(str(args.get('external_reference_seed') or '웹소설 현실감/장르 맥락 보강 자료'))
      focused_query = ' / '.join(base_terms)
      focused_question = (
          f"웹소설 <<{args.get('title') or 'Untitled'}>> Ep.{args.get('episode_number')} "
          f"현실감 보강용 자료를 찾아줘. 핵심: {focused_query}. "
          "작품의 장르/시대/직업/문화/플랫폼 맥락에서 바로 쓸 수 있는 짧은 포인트만. "
          "작품 캐논은 Kumiho가 우선이며 OpenCrab 자료는 외부 레퍼런스다."
      )
      keyword_query = focused_query

      q1 = rpc_call('opencrab_query', {'query': focused_question, 'top_k': 5}, 1)
      q2 = rpc_call('opencrab_search_documents', {'query': keyword_query, 'limit': 8, 'scan_limit': 2000}, 2)

      tool_calls = [
          {'tool': 'opencrab_query', 'query': focused_question[:300], 'result': 'success' if q1.get('ok') else 'error'},
          {'tool': 'opencrab_search_documents', 'query': keyword_query[:300], 'result': 'success' if q2.get('ok') else 'error'},
      ]

      reference_points = []
      answer = ''
      if isinstance(q1.get('inner'), dict):
          answer = str(q1['inner'].get('answer') or q1['inner'].get('text') or '')
      if answer:
          reference_points.append({
              'topic': 'OpenCrab query answer',
              'usable_detail': answer[:800],
              'source_hint': 'opencrab_query',
              'canon_priority_note': 'Kumiho canon 우선',
          })
      for ev in compact_evidence(q1.get('inner'), 3) + compact_evidence(q2.get('inner'), 5):
          detail = ev.get('snippet') or ev.get('title')
          if detail:
              reference_points.append({
                  'topic': ev.get('title') or 'OpenCrab evidence',
                  'usable_detail': detail[:700],
                  'source_hint': ev.get('id') or 'OpenCrab evidence',
                  'canon_priority_note': 'Kumiho canon 우선',
              })

      if not reference_points:
          reference_points.append({
              'topic': 'OpenCrab evidence unavailable',
              'usable_detail': 'OpenCrab MCP 호출은 수행했지만 이번 질의에 바로 쓸 수 있는 근거가 없거나 응답 파싱이 제한적이었다. 캐논/회차 설계는 Kumiho context를 우선한다.',
              'source_hint': 'OpenCrab direct MCP call attempted',
              'canon_priority_note': 'Kumiho canon 우선',
          })

      result = {
          'external_reference_context': {
              'used_opencrab': True,
              'direct_mcp': True,
              'tool_calls_attempted': tool_calls,
              'queries': [focused_question, keyword_query],
              'reference_points': reference_points[:8],
              'realism_checks': [
                  {
                      'check': 'OpenCrab 외부 레퍼런스는 Kumiho 캐논을 덮어쓰지 않는다.',
                      'result': 'pass',
                      'risk': 'low',
                  }
              ],
              'joke_or_texture_material': [],
              'conflicts_with_kumiho': [],
              'do_not_import_as_canon': [
                  'OpenCrab 자료는 외부 레퍼런스이며 Kumiho 캐논을 자동 변경하지 않는다.'
              ],
          },
          'summary': 'OpenCrab MCP direct query completed; evidence compacted for downstream episode planning.',
      }
        sys.stdout.buffer.write(json.dumps(result, ensure_ascii=False).encode('utf-8'))
    args:
      episode_number: ${next-episode-info.output_data.episode_number}
      volume: ${next-episode-info.output_data.volume}
      volume_bundle: ${next-episode-info.output_data.volume_bundle}
      episode_goal: ${inputs.episode_goal}
      must_include: ${inputs.must_include}
      opencrab_query: ${inputs.opencrab_query}
      pacing_mode: ${inputs.pacing_mode}
      context_artifact: ${episode-context.output_data.artifact_path}
      volume_alignment_artifact: ${volume-canon-alignment.output_data.artifact_path}
      title: ${project-config.output_data.title}
      external_reference_seed: ${project-config.output_data.external_reference_seed}
      genre_modules: ${project-config.output_data.genre_modules_text}
    timeout: 180
    allow_failure: true
  depends_on:
  - episode-context
  - volume-canon-alignment
- id: episode-intent-planner
  name: episode_intent_planner
  type: agent
  position:
    x: 80
    y: -120
  description: 이번 회차의 기능, 독자 보상, 진행할 storyline/foreshadow/character-state를 결정한다.
  agent:
    agent_type: codex
    role: planner
    template: episode-beat-planner
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} intent planning.

      너는 다음 회차 intent planner다.
      원고를 쓰지 않는다.
      이번 회차 하나의 기능만 정한다.

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      RELATIONSHIP_PRESSURE_PLAN:
      ${relationship-pressure-plan.output}

      EXTERNAL_REFERENCE_CONTEXT:
      ${opencrab-reference-builder.output}

      최신 production-ready 회차:
      ${latest_production_episode.output_data.artifact_content}

      입력:
      - episode_number: ${next-episode-info.output_data.episode_number}
      - target_length: ${inputs.target_length}
      - episode_goal: ${inputs.episode_goal}
      - must_include: ${inputs.must_include}
      - avoid: ${inputs.avoid}
      - continuity_context: ${inputs.continuity_context}
      - pacing_mode: ${inputs.pacing_mode}

      하드 원칙:
      - Kumiho canon이 OpenCrab reference보다 우선한다.
      - VOLUME_CANON_ALIGNMENT.temporal_continuity를 사용해 opening temporal policy를 정한다.
      - 같은 장소 + 같은 주요 등장인물 + 같은 active_event로 이어지는 opening_state라면 latest production-ready 회차의 closing/next_hook과 current_story_time에서 바로 이어져야 한다.
      - 다른 장소/다른 인물의 병렬 장면, 명시적 시간 경과, 플래시백/플래시포워드는 가능하지만 temporal_anchor.time_shift_policy와 required_marker에 반드시 기록한다.
      - 무표식 시간 점프/날짜 변경/장면 시간 재배치는 금지하며, 필요하면 canon_risks에 기록한다.
      - conflict_warnings를 반드시 반영한다.
      - 주인공 보상/성공은 project_config와 Kumiho canon의 pacing, relationship stage, reveal lock 안에서만 허용한다.
      - 시대/기술/접속 방식 제약은 project_config.krefs와 Kumiho current snapshots에서 확인된 것만 hard constraint로 둔다.
      - 인물별 언어, 호칭, 대화 방식 제약은 character-state와 style-guide를 따른다.
      - 장르별 질감이나 유머는 genre_modules에 맞을 때만 쓰고, 장면 압력과 캐논을 망가뜨리지 않는다.
      - 현재 권 plan에서 회차 기능을 고른다. generic pacing만으로 결정하지 않는다.
      - 이번 회차 장면 압력으로 쓸 active relationship edge 2~4개를 고른다.
      - 이름 있는 인물이 등장하면 character revision kref와 관계 edge 근거를 남긴다.
      - P2 인물은 권별 bundle, prior hook, 사용자 명시 입력, 강한 graph pressure가 있을 때만 사용한다.
      - 새로운 관계 변화는 조용히 캐논화하지 말고 patch candidate로 보낸다.
      - 이번 단계에서는 대사/본문을 쓰지 않는다.

      반드시 FINAL_OUTPUT YAML만 출력하라.

      FINAL_OUTPUT:
      episode_intent:
        episode_number: ${next-episode-info.output_data.episode_number}
        volume: ${next-episode-info.output_data.volume}
        working_title: ""
        episode_purpose: ""
        reader_reward: ""
        main_conflict: ""
        emotional_shift: ""
        temporal_anchor:
          current_story_time: ""
          starts_at: ""
          ends_at: ""
          previous_scene_location: ""
          opening_location: ""
          previous_scene_cast: []
          opening_cast: []
          active_event: ""
          same_scene_continuation: true
          allowed_elapsed_time: ""
          time_shift_policy: "same_scene_continuity|required_marker|parallel_scene|flashback|explicit_elapsed_time"
          time_jump_allowed: false
          required_temporal_marker: ""
          source: "current-timeline-progress-snapshot|latest-production-episode|operator-input"
        technical_or_developer_hook: ""
        selected_storylines:
          - id: ""
            intended_progress: ""
        selected_foreshadow_threads:
          - id: ""
            intended_progress: ""
            reveal_level: "hint"
        active_relationship_edges:
          - source_kref: ""
            target_kref: ""
            relation_kind: ""
            scene_pressure: ""
        character_state_moves:
          - character: ""
            from_state: ""
            to_state_candidate: ""
            evidence_to_create: ""
        required_beats:
          - order: 1
            beat: ""
            purpose: ""
        forbidden_content: []
        ending_hook: ""
        canon_risks: []
        assumptions: []
    timeout: 1800
    max_turns: 30
  assign: episode-beat-planner
  depends_on:
  - episode-context
  - volume-canon-alignment
  - relationship-pressure-plan
  - opencrab-reference-builder
- id: episode-beat-planner
  name: episode_beat_planner
  type: agent
  position:
    x: 460
    y: -120
  description: intent를 6000자 회차 작성용 장면 비트로 분해한다.
  agent:
    agent_type: codex
    role: planner
    template: episode-beat-planner
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} beat plan.

      너는 episode beat planner다.
      원고를 쓰지 않는다.
      intent를 회차 작성 가능한 scene beats로 변환한다.

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      RELATIONSHIP_PRESSURE_PLAN:
      ${relationship-pressure-plan.output}

      EPISODE_INTENT:
      ${episode-intent-planner.output}

      EXTERNAL_REFERENCE_CONTEXT:
      ${opencrab-reference-builder.output}

      설계 기준:
      - 목표 분량: ${inputs.target_length}
      - EPISODE_INTENT.temporal_anchor와 VOLUME_CANON_ALIGNMENT.temporal_continuity를 모든 scene beat의 시간 기준으로 사용한다.
      - 같은 장소 + 같은 주요 등장인물 + 같은 active_event인 beat는 직전 장면 시간에서 연속되어야 한다.
      - 다른 장소/다른 인물의 병렬 장면이나 명시적 시간 경과/플래시백은 허용하되 scene_time_shift.kind와 required_marker를 명시한다.
      - 장면마다 scene_time을 명시하고, 무표식 시간 점프를 만들지 않는다.
      - 4~7개 scene beat로 구성한다.
      - 각 beat는 목적, 갈등, 정보 공개량, 캐논 의존성, 금지사항을 포함한다.
      - 각 beat는 active_relationship_edges_used를 포함한다.
      - 관계 edge는 설명이 아니라 장면 압력으로 써야 한다.
      - 마지막 beat는 다음 회차를 누르게 하는 hook이어야 한다.
      - 대사/본문 문장화 금지. 설계만 한다.

      반드시 FINAL_OUTPUT YAML만 출력하라.

      FINAL_OUTPUT:
      episode_blueprint:
        episode_number: ${next-episode-info.output_data.episode_number}
        title: ""
        target_length: "${inputs.target_length}"
        logline: ""
        temporal_anchor:
          current_story_time: ""
          starts_at: ""
          ends_at: ""
          previous_scene_location: ""
          opening_location: ""
          previous_scene_cast: []
          opening_cast: []
          active_event: ""
          same_scene_continuation: true
          time_shift_policy: "same_scene_continuity|required_marker|parallel_scene|flashback|explicit_elapsed_time"
          time_jump_allowed: false
          required_temporal_marker: ""
          continuity_source: ""
        opening_state: ""
        closing_state: ""
        scene_beats:
          - order: 1
            scene_time: ""
            scene_time_shift:
              kind: "continuous|parallel_same_time|explicit_elapsed_time|flashback|flashforward"
              elapsed_from_previous_scene: ""
              required_marker: ""
            same_location_as_previous: false
            same_cast_as_previous: false
            scene_goal: ""
            location: ""
            characters: []
            conflict: ""
            action_or_decision: ""
            emotional_turn: ""
            technical_detail_or_joke: ""
            active_relationship_edges_used:
              - source_kref: ""
                target_kref: ""
                relation_kind: ""
                scene_pressure: ""
            required_canon: []
            forbidden_canon: []
            source_krefs: []
            volume_plan_alignment: ""
            timeline_alignment: ""
            character_state_before_after:
              - character_kref: ""
                before: ""
                after: ""
            foreshadow_in: []
            foreshadow_out: []
            hard_rule_risks: []
            forbidden_shortcuts: []
        continuity_requirements:
          - ""
        style_requirements:
          - ""
        ending_hook:
          beat: ""
          line_intent: ""
        patch_targets:
          character_states: []
          storylines: []
          foreshadow_threads: []
          timeline_events: []
          relationships: []
    timeout: 1800
    max_turns: 35
  assign: episode-beat-planner
  depends_on:
  - episode-intent-planner
- id: episode-draft-writer
  name: episode_draft_writer
  type: agent
  position:
    x: 840
    y: -120
  description: 회차 초안을 작성한다.
  agent:
    agent_type: codex
    role: writer
    template: web-novel-episode-writer
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} 초안 작성.

      너는 한국 웹소설 초고 작성자다.
      아래 blueprint에 따라 이번 회차 본문만 작성한다.

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      RELATIONSHIP_PRESSURE_PLAN:
      ${relationship-pressure-plan.output}

      EPISODE_BLUEPRINT:
      ${episode-beat-planner.output}

      EXTERNAL_REFERENCE_CONTEXT:
      ${opencrab-reference-builder.output}

      작성 조건:
      - 목표 분량: ${inputs.target_length}
      - EPISODE_BLUEPRINT.temporal_anchor와 각 beat.scene_time/scene_time_shift를 반드시 따른다.
      - 첫 장면이 이전 production-ready 회차와 같은 장소 + 같은 주요 등장인물 + 같은 active_event라면 직전 장면에서 시간 연속으로 이어야 한다.
      - 다른 장소/다른 인물의 병렬 장면, 명시적 시간 경과, 플래시백/플래시포워드는 허용하되 본문에 독자가 알아볼 temporal marker를 넣는다.
      - 날짜/시간대/경과시간을 무표식으로 바꾸지 않는다.
      - 한국어 웹소설 문체.
      - 장면 중심, 독자 보상, 후킹 우선.
      - 관계 edge는 설명하지 말고 대화/선택/지연/오해/협상/충돌로 드러낸다.
      - genre_modules에 맞는 장르 질감/유머/전문성을 자연스럽게 사용한다.
      - 하드 캐논 위반 금지.
      - OpenCrab 자료는 질감과 현실성 보강으로만 사용한다.
      - 아직 공개하면 안 되는 spoiler_locked/forbidden_canon 노출 금지.
      - 새로운 관계/상태 변화가 필요하면 PATCH_CANDIDATE_NOTES에 남긴다.
      - 메타 설명, 분석 과정, YAML 출력 금지.
      - 본문 앞에 제목을 붙인다.

      출력은 반드시 다음 형식을 따른다.

      FINAL_OUTPUT:
      # ${next-episode-info.output_data.episode_id}. <제목>

      <회차 본문>

      ---
      PATCH_CANDIDATE_NOTES:
      - <필요 없으면 none>
    timeout: 3600
    max_turns: 80
  assign: web-novel-episode-writer
  depends_on:
  - episode-beat-planner
- id: episode-prose-reviser
  name: episode_prose_reviser
  type: agent
  position:
    x: 840
    y: 220
  description: 초안을 연재 플랫폼 문체로 정리하고 호흡/후킹/장르 질감을 강화한다. 캐논은 바꾸지 않는다.
  agent:
    agent_type: codex
    role: architect
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} 문체 개정.

      너는 한국 웹소설 문장 개정자다.
      캐논/사건/인물 관계를 새로 만들지 않는다.
      아래 초안을 더 읽히게 다듬는다.

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      RELATIONSHIP_PRESSURE_PLAN:
      ${relationship-pressure-plan.output}

      EPISODE_BLUEPRINT:
      ${episode-beat-planner.output}

      DRAFT:
      ${episode-draft-writer.output}

      개정 기준:
      - 한국 웹소설 호흡 강화.
      - 장면 전환 명확화.
      - 시간 연속성 보존: EPISODE_BLUEPRINT.temporal_anchor와 beat.scene_time/scene_time_shift를 변경하지 않는다. 단, 더 명확한 temporal marker를 추가하는 것은 허용한다.
      - 마지막 hook 강화.
      - 장르 질감/유머/전문성이 억지면 줄이고, 자연스러우면 살린다.
      - 캐논 변경 금지.
      - 새로운 사건 추가 금지.
      - 관계 edge는 장면 내부 압력으로만 작동해야 한다.
      - spoiler_locked/forbidden_canon 노출 금지.

      출력은 반드시 다음 형식을 따른다.

      FINAL_OUTPUT:
      # ${next-episode-info.output_data.episode_id}. <제목>

      <개정 본문>

      ---
      PATCH_CANDIDATE_NOTES:
      - <필요 없으면 none>
    timeout: 3600
    max_turns: 80
  skills:
  - humanize-korean
  assign: web-novel-arc-room-architect
  depends_on:
  - episode-draft-writer
- id: draft-canon-auditor
  name: draft_canon_auditor
  type: agent
  position:
    x: 460
    y: 540
  description: 개정 원고의 캐논 위반, 관계 그래프 위반, 스포일러 누출, OpenCrab 오염, 연속성 문제를 감사한다.
  agent:
    agent_type: codex
    role: reviewer
    template: continuity-lorekeeper
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} 캐논 감사.

      너는 continuity/canon auditor다.
      원고를 새로 쓰지 않는다.
      문제와 수정 지시만 낸다.

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      RELATIONSHIP_PRESSURE_PLAN:
      ${relationship-pressure-plan.output}

      EPISODE_BLUEPRINT:
      ${episode-beat-planner.output}

      EXTERNAL_REFERENCE_CONTEXT:
      ${opencrab-reference-builder.output}

      REVISED_DRAFT:
      ${episode-prose-reviser.output}

      감사 기준:
      - hard canon rule 위반 여부.
      - character-state / relationship / timeline 모순 여부.
      - 모든 주요 관계 변화가 기존 REFERENCED edge를 인용하거나 patch candidate로 들어갔는지.
      - 극중 시간/날짜/경과시간이 current-timeline-progress-snapshot, latest production-ready ending, EPISODE_BLUEPRINT.temporal_anchor와 충돌하지 않는지.
      - 같은 장소 + 같은 주요 등장인물 + 같은 active_event인데 시간이 달라졌다면 특별한 상황/플래시백/명시적 시간 경과 marker가 없는 한 BLOCKED 또는 NEEDS_CHANGES로 판정한다.
      - 다른 장소/다른 인물의 병렬 장면이나 명시적 시간 경과는 허용하되, 본문에 temporal marker가 있는지 확인한다.
      - 현재 권 bundle을 넘어서는 미래 스포일러가 없는지.
      - P2/inactive 인물을 근거 없이 핵심 압력으로 승격하지 않았는지.
      - spoiler_locked / forbidden_canon 노출 여부.
      - OpenCrab reference가 Kumiho canon을 덮어쓴 부분 여부.
      - 주인공이 project_config와 Kumiho canon의 pacing/relationship/reveal constraints보다 쉽게 보상이나 해결을 얻었는지 여부.
      - 인물별 언어, 호칭, 대화 방식, 접속/기술/세계관 제약 위반 여부.
      - 다음 회차 hook이 충분한지.
      - patch_targets와 실제 본문 변화가 일치하는지.

      반드시 FINAL_OUTPUT YAML만 출력하라.

      FINAL_OUTPUT:
      verdict: "APPROVED|NEEDS_CHANGES|BLOCKED"
      production_ready_candidate: true
      audit:
        blocking_issues: []
        continuity_issues: []
        relationship_graph_violations: []
        volume_bundle_violations: []
        style_rule_violations: []
        spoiler_leaks: []
        opencrab_contamination: []
        weak_scene_or_hook_issues: []
        money_rule_risks: []
        required_fixes:
          - target: ""
            fix: ""
            reason: ""
        patch_notes:
          expected_character_state_updates: []
          expected_storyline_updates: []
          expected_foreshadow_updates: []
          expected_timeline_events: []
          expected_relationship_updates: []
    timeout: 1800
    max_turns: 40
  assign: continuity-lorekeeper
  depends_on:
  - episode-prose-reviser
- id: episode-finalizer
  name: episode_finalizer
  type: agent
  position:
    x: 846
    y: 752
  description: 캐논 감사 결과를 반영해 최종 원고를 확정한다.
  agent:
    agent_type: codex
    role: coder
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} 최종 원고 확정.

      너는 최종 원고 편집자다.
      캐논 감사 결과의 required_fixes만 반영한다.
      새 사건을 만들지 않는다.

      REVISED_DRAFT:
      ${episode-prose-reviser.output}

      CANON_AUDIT:
      ${draft-canon-auditor.output}

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      RELATIONSHIP_PRESSURE_PLAN:
      ${relationship-pressure-plan.output}

      규칙:
      - verdict가 APPROVED이면 문체/형식만 최소 정리한다.
      - NEEDS_CHANGES이면 required_fixes만 반영한다.
      - BLOCKED이면 문제 구간을 안전하게 축소/수정한다.
      - 관계/상태 변화 추가 금지. 필요한 변화는 patch candidate note로 남긴다.
      - 출력은 최종 회차 본문만 낸다.

      FINAL_OUTPUT:
      # ${next-episode-info.output_data.episode_id}. <제목>

      <최종 본문>
    timeout: 3600
    max_turns: 80
  skills:
  - humanize-korean
  assign: korean-webnovel-prose-writer
  depends_on:
  - draft-canon-auditor
- id: final-canon-auditor
  name: final_canon_auditor
  type: agent
  position:
    x: 462
    y: 960
  description: 최종 원고에 대한 최종 캐논 게이트. production-ready 저장 가능 여부를 판정한다.
  agent:
    agent_type: codex
    role: reviewer
    template: continuity-lorekeeper
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} 최종 캐논 게이트.

      너는 최종 게이트 감사자다.
      원고를 수정하지 않는다.
      production-ready로 저장 가능한지만 판정한다.

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      RELATIONSHIP_PRESSURE_PLAN:
      ${relationship-pressure-plan.output}

      EPISODE_BLUEPRINT:
      ${episode-beat-planner.output}

      PRIOR_AUDIT:
      ${draft-canon-auditor.output}

      FINAL_EPISODE:
      ${episode-finalizer.output}

      판정:
      - 차단 이슈가 없으면 verdict APPROVED, production_ready true.
      - 캐논 위반/스포일러 누출/하드 룰 위반이 남아 있으면 BLOCKED, production_ready false.
      - unresolved relationship graph violation이 있으면 BLOCKED 또는 NEEDS_CHANGES.
      - current volume bundle 위반이 심하면 BLOCKED.
      - 극중 시간/날짜/경과시간이 current-timeline-progress-snapshot, latest production-ready ending, EPISODE_BLUEPRINT.temporal_anchor와 충돌하면 BLOCKED 또는 NEEDS_CHANGES.
      - 같은 장소 + 같은 주요 등장인물 + 같은 active_event에서 무표식 시간 변화가 있으면 BLOCKED 또는 NEEDS_CHANGES.
      - 다른 장소/다른 인물의 병렬 장면, 명시적 시간 경과, 플래시백은 temporal marker가 있으면 허용한다.
      - OpenCrab 오염이 있으면 BLOCKED.
      - 사소한 문장 문제만 있으면 APPROVED로 둔다.

      반드시 FINAL_OUTPUT YAML만 출력하라.

      FINAL_OUTPUT:
      verdict: "APPROVED|NEEDS_CHANGES|BLOCKED"
      production_ready: true
      approved_episode_title: ""
      final_gate:
        blocking_issues: []
        relationship_graph_violations: []
        volume_bundle_violations: []
        residual_risks: []
        required_before_publish: []
        notes: []
        relationship_edges_used:
          - source_kref: ""
            target_kref: ""
            relation_kind: ""
    timeout: 1200
    max_turns: 25
    output_fields:
    - verdict
    - production_ready
    - approved_episode_title
    - final_gate
  assign: continuity-lorekeeper
  depends_on:
  - episode-finalizer
- id: final-gate-router
  name: final_gate_router
  type: conditional
  position:
    x: 484
    y: 1174
  description: 최종 게이트 결과에 따라 production-ready 저장 또는 blocked 저장으로 라우팅한다.
  conditional:
    branches:
    - condition: ${{ final_canon_auditor.output_data.production_ready == True }}
      goto: canon-patch-builder
    - condition: default
      goto: emit-blocked-episode
  depends_on:
  - final-canon-auditor
- id: production-route-gate
  name: production_route_gate
  type: conditional
  position:
    x: 128
    y: 1170
  description: final gate가 production-ready일 때만 canon patch builder를 실행한다.
  conditional:
    branches:
    - condition: ${{ final_canon_auditor.output_data.production_ready == True }}
      goto: canon-patch-builder
    - condition: default
      goto: emit-blocked-episode
  depends_on:
  - final-gate-router
- id: canon-patch-builder
  name: canon_patch_builder
  type: agent
  position:
    x: 80
    y: 1380
  description: 최종 회차로 인해 필요한 Kumiho 캐논 업데이트 후보를 만든다. 실제 적용은 별도 승인/패치 워크플로우에서 수행한다.
  agent:
    agent_type: codex
    role: reviewer
    template: continuity-lorekeeper
    prompt: |
      <<${project-config.output_data.title}>> Ep.${next-episode-info.output_data.episode_number} canon patch candidate 생성.

      너는 canon patch builder다.
      실제 Kumiho item/revision/edge를 수정하지 않는다.
      최종 원고로 인해 바뀌어야 할 character-state, storyline, foreshadow-thread, timeline-event, relationship 후보만 작성한다.

      KUMIHO_CONTEXT_PACK:
      ${episode-context.output}

      VOLUME_CANON_ALIGNMENT:
      ${volume-canon-alignment.output}

      RELATIONSHIP_PRESSURE_PLAN:
      ${relationship-pressure-plan.output}

      EPISODE_BLUEPRINT:
      ${episode-beat-planner.output}

      FINAL_GATE_ROUTER:
      ${final-gate-router.output}

      PRODUCTION_ROUTE:
      ${production-route-gate.output}

      FINAL_GATE:
      ${final_canon_auditor.output}

      FINAL_EPISODE:
      ${episode-finalizer.output}

      작성 기준:
      - source_revision_krefs에는 KUMIHO_CONTEXT_PACK의 locked manifest를 참조한다.
      - evidence_locator는 가능하면 최종 원고의 장면/문단 설명으로 작성한다.
      - timeline_events에는 단순 사건 진행뿐 아니라 current_story_time, previous_episode_end_time, episode_start_time, episode_end_time, elapsed_time, next_episode_start_anchor를 반드시 후보로 기록한다.
      - 극중 시간 변화가 없더라도 timeline_events에 no_time_jump/continued_from_previous 같은 continuity marker를 남긴다.
      - current tag 적용은 하지 않는다. patch_status는 candidate로 둔다.
      - 관계 edge/state 변화는 relationship_edge_candidates / relationship_state_delta_candidates에만 기록한다.
      - 변화가 없는 영역은 no_update_needed에 기록한다.

      반드시 FINAL_OUTPUT YAML만 출력하라.

      FINAL_OUTPUT:
      canon_patch:
        patch_id: "${next-episode-info.output_data.patch_name}"
        patch_status: "candidate"
        apply_policy: "requires-human-approval"
        source_episode:
          entity_name: "${next-episode-info.output_data.entity_name}"
          episode_number: ${next-episode-info.output_data.episode_number}
        source_context_pack: "${next-episode-info.output_data.context_pack_name}"
        source_revision_krefs:
          - "${episode-context.output_data.source_krefs}"
        proposed_revision_updates:
          character_states: []
          storylines: []
          foreshadow_threads: []
          timeline_events: []
          relationships: []
        relationship_edge_candidates:
          - source_character_kref: ""
            target_character_kref: ""
            proposed_relation_kind: ""
            proposed_relation_layer: "operational|thematic"
            proposed_relation_strength: "high|medium|low"
            evidence_locator: ""
            source_episode_revision_kref: ""
            source_context_pack_kref: ""
            apply_policy: "requires-human-approval"
        relationship_state_delta_candidates:
          - relation_label: ""
            before: ""
            after: ""
            evidence_locator: ""
            apply_policy: "requires-human-approval"
        proposed_edges:
          - from: "${next-episode-info.output_data.entity_name}"
            edge_type: "ADVANCES"
            to: ""
            reason: ""
            evidence_locator: ""
        no_update_needed: []
        approval_notes:
          - "이 patch는 candidate다. 별도 canon-patch-apply 워크플로우에서 검토 후 current revision/tag/edge를 갱신한다."
    timeout: 1800
    max_turns: 45
  assign: continuity-lorekeeper
  depends_on:
  - production-route-gate
- id: emit-final-episode
  name: emit_final_episode
  type: output
  position:
    x: 846
    y: 1594
  description: 최종 회차를 production-ready webnovel-episode로 저장한다.
  output:
    format: markdown
    template: |
      ${episode-finalizer.output}

      ---

      ## Production Metadata

      - series: ${project-config.output_data.title}
      - episode_number: ${next-episode-info.output_data.episode_number}
      - volume: ${next-episode-info.output_data.volume}
      - workflow: canonworks-serial-episode-factory
      - workflow_version: 2.5
      - context_pack: ${next-episode-info.output_data.context_pack_name}
      - canon_patch_candidate: ${next-episode-info.output_data.patch_name}
      - canon_bundle: ${project-config.output_data.main_canon_bundle}
      - style_bundle: ${project-config.output_data.production_style_bundle}
      - volume_bundle: ${next-episode-info.output_data.volume_bundle}
      - relationship_map: ${project-config.output_data.relationship_map_artifact_kref}

      ## Final Gate

      ${final-canon-auditor.output}
    entity_name: ${next-episode-info.output_data.entity_name}
    entity_kind: webnovel-episode
    entity_tag: production-ready
    entity_space: ${project-config.output_data.episode_space}
    metadata_target: revision
    entity_metadata:
      series: ${project-config.output_data.title}
      workflow: canonworks-serial-episode-factory
      workflow_version: '2.5'
      episode_number: ${next-episode-info.output_data.episode_number}
      episode_id: ${next-episode-info.output_data.episode_id}
      volume: ${next-episode-info.output_data.volume}
      volume_bundle: ${next-episode-info.output_data.volume_bundle}
      target_length: ${inputs.target_length}
      context_pack: ${next-episode-info.output_data.context_pack_name}
      canon_patch_candidate: ${next-episode-info.output_data.patch_name}
      relationship_map_artifact: ${project-config.output_data.relationship_map_artifact_kref}
      production_ready: 'true'
      story_slug: ${project-config.output_data.story_slug}
      project_id: ${project-config.output_data.project_id}
      language: ${project-config.output_data.language}
      genre_modules: ${project-config.output_data.genre_modules_text}
  depends_on:
  - production-emit-gate
  - canon-patch-builder
- id: production-emit-gate
  name: production_emit_gate
  type: conditional
  position:
    x: 486
    y: 1414
  description: final gate가 production-ready일 때만 production 산출물 저장을 허용한다.
  conditional:
    branches:
    - condition: ${{ final_canon_auditor.output_data.production_ready == True }}
      goto: emit-final-episode
    - condition: default
      goto: emit-blocked-episode
  depends_on:
  - final-gate-router
- id: emit-canon-patch-candidate
  name: emit_canon_patch_candidate
  type: output
  position:
    x: 80
    y: 1598
  description: 캐논 업데이트 후보를 candidate patch로 저장한다. 적용은 별도 승인 워크플로우에서 수행한다.
  output:
    format: markdown
    template: |
      # Canon Patch Candidate: ${next-episode-info.output_data.patch_name}

      ${canon-patch-builder.output}

      ---

      ## Source Episode

      ${next-episode-info.output_data.entity_name}

      ## Context Pack

      ${next-episode-info.output_data.context_pack_name}
    entity_name: ${next-episode-info.output_data.patch_name}
    entity_kind: canon-patch
    entity_tag: candidate
    entity_space: ${project-config.output_data.patch_space}
    metadata_target: revision
    entity_metadata:
      series: ${project-config.output_data.title}
      workflow: canonworks-serial-episode-factory
      workflow_version: '2.5'
      patch_status: candidate
      apply_policy: requires-human-approval
      source_episode: ${next-episode-info.output_data.entity_name}
      episode_number: ${next-episode-info.output_data.episode_number}
      volume: ${next-episode-info.output_data.volume}
      context_pack: ${next-episode-info.output_data.context_pack_name}
      story_slug: ${project-config.output_data.story_slug}
      project_id: ${project-config.output_data.project_id}
      language: ${project-config.output_data.language}
      genre_modules: ${project-config.output_data.genre_modules_text}
  depends_on:
  - production-emit-gate
  - canon-patch-builder
- id: emit-context-pack
  name: emit_context_pack
  type: output
  position:
    x: -318
    y: 1576
  description: 이번 회차 생성에 사용한 locked Kumiho context pack을 저장한다. 재현성과 추적용이다.
  output:
    format: markdown
    template: |
      # Episode Context Pack: ${next-episode-info.output_data.context_pack_name}

      ## Next Episode Info
      ${next-episode-info.output}

      ## Kumiho Context
      ${episode-context.output}

      ## Volume Canon Alignment
      ${volume-canon-alignment.output}

      ## Relationship Pressure Plan
      ${relationship-pressure-plan.output}

      ## OpenCrab Reference Context
      ${opencrab-reference-builder.output}

      ## Intent Planning
      ${episode-intent-planner.output}

      ## Beat Plan
      ${episode-beat-planner.output}
    entity_name: ${next-episode-info.output_data.context_pack_name}
    entity_kind: context-pack
    entity_tag: ready
    entity_space: ${project-config.output_data.context_pack_space}
    metadata_target: revision
    entity_metadata:
      series: ${project-config.output_data.title}
      workflow: canonworks-serial-episode-factory
      workflow_version: '2.5'
      episode_number: ${next-episode-info.output_data.episode_number}
      volume: ${next-episode-info.output_data.volume}
      source_latest_episode: ${latest_production_episode.output_data.kref}
      context_mode: graph_augmented_context
      canon_bundle: ${project-config.output_data.main_canon_bundle}
      style_bundle: ${project-config.output_data.production_style_bundle}
      volume_bundle: ${next-episode-info.output_data.volume_bundle}
      relationship_map_artifact: ${project-config.output_data.relationship_map_artifact_kref}
      story_slug: ${project-config.output_data.story_slug}
      project_id: ${project-config.output_data.project_id}
      language: ${project-config.output_data.language}
      genre_modules: ${project-config.output_data.genre_modules_text}
  depends_on:
  - episode-context
  - volume-canon-alignment
  - relationship-pressure-plan
  - opencrab-reference-builder
  - episode-intent-planner
  - episode-beat-planner
- id: production-output-gate
  name: production_output_gate
  type: conditional
  position:
    x: 466
    y: 1878
  description: production-ready 산출물이 모두 생성된 경우에만 운영 번들 업데이트로 진행한다.
  conditional:
    branches:
    - condition: ${{ emit_final_episode.status == 'completed' and emit_canon_patch_candidate.status == 'completed' and emit_context_pack.status
        == 'completed' and final_canon_auditor.output_data.production_ready == True }}
      goto: update-output-bundles
    - condition: default
      goto: run-summary
  depends_on:
  - emit-final-episode
  - emit-canon-patch-candidate
  - emit-context-pack
- id: update-output-bundles
  name: update_output_bundles
  type: kumiho_bundle_update
  position:
    x: 452
    y: 2112
  description: production-ready 산출물을 append-only 운영 번들, 권별 episode bundle, context/patch bundle에 등록한다. 캐논 current 상태는 갱신하지
    않는다.
  retry: 1
  kumiho:
    project: ${project-config.output_data.project_id}
    mode: add_members
    create_if_missing: true
    idempotent: true
    allow_protected: false
    updates:
    - bundle: ${project-config.output_data.production_episodes_bundle}
      add:
      - item_kref: ${emit-final-episode.output_data.entity_kref}
        reason: Ep.${next-episode-info.output_data.episode_number} production-ready episode generated by episode factory v2.5
        metadata:
          episode_number: ${next-episode-info.output_data.episode_number}
          episode_id: ${next-episode-info.output_data.episode_id}
          volume: ${next-episode-info.output_data.volume}
          workflow: canonworks-serial-episode-factory
          workflow_version: '2.5'
    - bundle: ${next-episode-info.output_data.volume_episode_bundle}
      add:
      - item_kref: ${emit-final-episode.output_data.entity_kref}
        reason: Production episode belongs to ${next-episode-info.output_data.volume_bundle}
        metadata:
          episode_number: ${next-episode-info.output_data.episode_number}
          episode_id: ${next-episode-info.output_data.episode_id}
          volume: ${next-episode-info.output_data.volume}
          volume_bundle: ${next-episode-info.output_data.volume_bundle}
          workflow: canonworks-serial-episode-factory
    - bundle: ${project-config.output_data.context_packs_bundle}
      add:
      - item_kref: ${emit-context-pack.output_data.entity_kref}
        reason: Locked context pack used for Ep.${next-episode-info.output_data.episode_number}
        metadata:
          episode_number: ${next-episode-info.output_data.episode_number}
          episode_id: ${next-episode-info.output_data.episode_id}
          volume: ${next-episode-info.output_data.volume}
    - bundle: ${project-config.output_data.canon_patch_candidates_bundle}
      add:
      - item_kref: ${emit-canon-patch-candidate.output_data.entity_kref}
        reason: Candidate canon patch generated from Ep.${next-episode-info.output_data.episode_number}
        metadata:
          episode_number: ${next-episode-info.output_data.episode_number}
          episode_id: ${next-episode-info.output_data.episode_id}
          volume: ${next-episode-info.output_data.volume}
          patch_status: candidate
          apply_policy: requires-human-approval
    fail_if_missing_bundle: true
    fail_if_missing_item: true
  depends_on:
  - production-output-gate
- id: emit-blocked-episode
  name: emit_blocked_episode
  type: output
  position:
    x: 844
    y: 1960
  description: 최종 게이트에서 막힌 원고를 blocked 상태로 저장한다. production-ready로 쓰지 않는다.
  output:
    format: markdown
    template: |
      # BLOCKED: ${next-episode-info.output_data.entity_name}

      ## Final Gate

      ${final-canon-auditor.output}

      ---

      ## Blocked Draft

      ${episode-finalizer.output}

      ---

      ## Context Pack

      ${episode-context.output}
    entity_name: ${next-episode-info.output_data.blocked_name}
    entity_kind: webnovel-episode
    entity_tag: blocked
    entity_space: ${project-config.output_data.episode_space}
    metadata_target: revision
    entity_metadata:
      series: ${project-config.output_data.title}
      workflow: canonworks-serial-episode-factory
      workflow_version: '2.5'
      episode_number: ${next-episode-info.output_data.episode_number}
      episode_id: ${next-episode-info.output_data.episode_id}
      volume: ${next-episode-info.output_data.volume}
      volume_bundle: ${next-episode-info.output_data.volume_bundle}
      production_ready: 'false'
      blocked_reason: final-canon-auditor
      story_slug: ${project-config.output_data.story_slug}
      project_id: ${project-config.output_data.project_id}
      language: ${project-config.output_data.language}
      genre_modules: ${project-config.output_data.genre_modules_text}
  depends_on:
  - final-gate-router
  - production-emit-gate
- id: blocked-output-gate
  name: blocked_output_gate
  type: conditional
  position:
    x: 838
    y: 2186
  description: blocked 원고가 실제 저장된 경우에만 blocked bundle 업데이트로 진행한다.
  conditional:
    branches:
    - condition: ${{ emit_blocked_episode.status == 'completed' and final_canon_auditor.output_data.production_ready != True
        }}
      goto: update-blocked-bundle
    - condition: default
      goto: run-summary
  depends_on:
  - emit-blocked-episode
- id: update-blocked-bundle
  name: update_blocked_bundle
  type: kumiho_bundle_update
  position:
    x: 836
    y: 2472
  description: 차단된 원고를 blocked episode bundle에 등록한다.
  retry: 1
  kumiho:
    project: ${project-config.output_data.project_id}
    mode: add_members
    create_if_missing: true
    idempotent: true
    allow_protected: false
    updates:
    - bundle: ${project-config.output_data.blocked_episodes_bundle}
      add:
      - item_kref: ${emit-blocked-episode.output_data.entity_kref}
        reason: Final canon gate blocked Ep.${next-episode-info.output_data.episode_number}
        metadata:
          episode_number: ${next-episode-info.output_data.episode_number}
          episode_id: ${next-episode-info.output_data.episode_id}
          volume: ${next-episode-info.output_data.volume}
          workflow: canonworks-serial-episode-factory
          workflow_version: '2.5'
    fail_if_missing_bundle: true
    fail_if_missing_item: true
  depends_on:
  - blocked-output-gate
- id: run-summary
  name: run_summary
  type: output
  position:
    x: 840
    y: 2700
  description: 이번 workflow 실행 결과 요약.
  output:
    format: markdown
    template: |
      # CanonWorks Episode Factory Result

      - Workflow: `canonworks-serial-episode-factory`
      - Version: `2.5`
      - Episode: `${next-episode-info.output_data.episode_number}`
      - Episode ID: `${next-episode-info.output_data.episode_id}`
      - Volume: `${next-episode-info.output_data.volume}`
      - Volume Bundle: `${next-episode-info.output_data.volume_bundle}`
      - Volume Episode Bundle: `${next-episode-info.output_data.volume_episode_bundle}`
      - Production Ready: `${final-canon-auditor.output_data.production_ready}`
      - Verdict: `${final-canon-auditor.output_data.verdict}`

      ## Context Sources

      - Canon Bundle: `${project-config.output_data.main_canon_bundle}`
      - Style Bundle: `${project-config.output_data.production_style_bundle}`
      - Volume Bundle: `${next-episode-info.output_data.volume_bundle}`
      - Relationship Map: `${project-config.output_data.relationship_map_artifact_kref}`
      - Context Pack Name: `${next-episode-info.output_data.context_pack_name}`

      ## Final Gate

      ${final-canon-auditor.output}
  depends_on:
  - final-canon-auditor
tags:
- canonworks
- creative
- writing
- serial
- kumiho
- workflow-template
