Kaynağa Gözat

引入主机任务合同编译器

SamLee 14 saat önce
ebeveyn
işleme
540f867b93
21 değiştirilmiş dosya ile 3197 ekleme ve 1949 silme
  1. 0 4
      script_build_host/src/script_build_host/agents/model_manifest.py
  2. 6 30
      script_build_host/src/script_build_host/agents/presets.py
  3. 3 2
      script_build_host/src/script_build_host/agents/prompts/context_access_protocol.md
  4. 5 29
      script_build_host/src/script_build_host/agents/prompts/contracts.py
  5. 31 72
      script_build_host/src/script_build_host/agents/prompts/script_planner.md
  6. 13 22
      script_build_host/src/script_build_host/application/mission_factory.py
  7. 503 0
      script_build_host/src/script_build_host/application/task_planning_v2.py
  8. 7 9
      script_build_host/src/script_build_host/domain/legacy_input_compatibility.py
  9. 556 87
      script_build_host/src/script_build_host/domain/task_capabilities.py
  10. 961 0
      script_build_host/src/script_build_host/domain/task_contract_compiler.py
  11. 248 141
      script_build_host/src/script_build_host/domain/task_contracts.py
  12. 15 75
      script_build_host/src/script_build_host/domain/task_policy.py
  13. 18 1
      script_build_host/src/script_build_host/infrastructure/task_contract_store.py
  14. 1 5
      script_build_host/src/script_build_host/tools/contracts.py
  15. 638 0
      script_build_host/tests/test_architecture_contract_compiler.py
  16. 9 14
      script_build_host/tests/test_mission_factory.py
  17. 27 1405
      script_build_host/tests/test_phase_two_contracts.py
  18. 12 45
      script_build_host/tests/test_prompt_quality.py
  19. 17 5
      script_build_host/tests/test_task_capabilities.py
  20. 123 0
      script_build_host/tests/test_task_planning_v2.py
  21. 4 3
      script_build_host/tests/test_task_policy.py

+ 0 - 4
script_build_host/src/script_build_host/agents/model_manifest.py

@@ -18,10 +18,6 @@ _VALIDATOR_CAPS = {
 }
 
 _LEGACY_MODEL_KEYS = {
-    "script_pattern_retrieval_worker": "model_name_means_pattern_relation",
-    "script_decode_retrieval_worker": "model_name_means_case",
-    "script_external_retrieval_worker": "model_name_means_data",
-    "script_knowledge_retrieval_worker": "model_name_means_knowledge",
     "script_retrieval_validator": "model_name_script_evaluator",
     "script_candidate_validator": "model_name_script_evaluator",
     "script_candidate_compare_worker": "model_name_script_evaluator",

+ 6 - 30
script_build_host/src/script_build_host/agents/presets.py

@@ -5,19 +5,16 @@ from __future__ import annotations
 from agent import AgentPreset, AgentRole
 from agent.core.presets import register_preset
 
-from script_build_host.domain.task_capabilities import task_capability
+from script_build_host.domain.task_capabilities import TaskCapabilityCatalog
 from script_build_host.domain.task_contracts import ScriptTaskKind
 
 from .prompts import (
     COMPARISON_WORKER_PROMPT,
     COMPOSE_WORKER_PROMPT,
-    DECODE_RETRIEVAL_WORKER_PROMPT,
     DIRECTION_WORKER_PROMPT,
     ELEMENT_SET_WORKER_PROMPT,
-    EXTERNAL_RETRIEVAL_WORKER_PROMPT,
-    KNOWLEDGE_RETRIEVAL_WORKER_PROMPT,
+    EVIDENCE_RETRIEVAL_WORKER_PROMPT,
     PARAGRAPH_WORKER_PROMPT,
-    PATTERN_RETRIEVAL_WORKER_PROMPT,
     PLANNER_PROMPT,
     PORTFOLIO_WORKER_PROMPT,
     RETRIEVAL_VALIDATOR_PROMPT,
@@ -67,7 +64,7 @@ def _worker(tools: list[str], *, prompt: str) -> AgentPreset:
 
 
 def _task_worker(kind: ScriptTaskKind, *, prompt: str) -> AgentPreset:
-    capability = task_capability(kind)
+    capability = TaskCapabilityCatalog().get(kind)
     return _worker(
         sorted(capability.allowed_tools - {"submit_attempt"}),
         prompt=prompt,
@@ -101,31 +98,10 @@ def register_script_presets() -> None:
         ),
     )
     register_preset(
-        "script_pattern_retrieval_worker",
+        "script_evidence_retrieval_worker",
         _task_worker(
-            ScriptTaskKind.PATTERN_RETRIEVAL,
-            prompt=PATTERN_RETRIEVAL_WORKER_PROMPT,
-        ),
-    )
-    register_preset(
-        "script_decode_retrieval_worker",
-        _task_worker(
-            ScriptTaskKind.DECODE_RETRIEVAL,
-            prompt=DECODE_RETRIEVAL_WORKER_PROMPT,
-        ),
-    )
-    register_preset(
-        "script_external_retrieval_worker",
-        _task_worker(
-            ScriptTaskKind.EXTERNAL_RETRIEVAL,
-            prompt=EXTERNAL_RETRIEVAL_WORKER_PROMPT,
-        ),
-    )
-    register_preset(
-        "script_knowledge_retrieval_worker",
-        _task_worker(
-            ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
-            prompt=KNOWLEDGE_RETRIEVAL_WORKER_PROMPT,
+            ScriptTaskKind.EVIDENCE_RETRIEVAL,
+            prompt=EVIDENCE_RETRIEVAL_WORKER_PROMPT,
         ),
     )
     register_preset(

+ 3 - 2
script_build_host/src/script_build_host/agents/prompts/context_access_protocol.md

@@ -15,7 +15,8 @@ Workbench Bootstrap 是 Host 按当前 Root、角色、Task、Attempt 和冻结
   query/filter 后复用 cursor。`NotModified` 表示同一查询的数据未变,应继续使用已有判断。
 - 详情读取返回 `next_cursor` 时,必须在实际需要完整内容时按顺序读到
   `next_cursor=null` 且 `exhausted=true`。不要重复读取已 exhausted 的 handle。
-- Validator 在提交 `passed` 前必须逐个读完全部 `required_handles` 的 `content`页;未读完就给出
-  PASS 会被 Host 以 `CONTEXT_NOT_EXHAUSTED` 拒绝。
+- Validator 在提交 `passed` 前必须逐个读完当前 `ValidationReadSession` 的全部
+  `required_handles`;阅读进度按 Validation 和 evidence revision 持久化。未读完、旧 revision
+  或跨 Validation 的 handle 会被框架以 `VALIDATION_EVIDENCE_UNAUTHORIZED` 拒绝。
 - Broker 详情分页只是读取已冻结数据,不算新的上游 Retrieval 调用。上游检索次数仍以当前
   Retrieval Task 的角色规则为准。

+ 5 - 29
script_build_host/src/script_build_host/agents/prompts/contracts.py

@@ -22,10 +22,7 @@ def _role_prompt(filename: str) -> str:
 
 
 PLANNER_PROMPT = _role_prompt("script_planner.md")
-PATTERN_RETRIEVAL_WORKER_PROMPT = _role_prompt("pattern_retrieval_worker.md")
-DECODE_RETRIEVAL_WORKER_PROMPT = _role_prompt("decode_retrieval_worker.md")
-EXTERNAL_RETRIEVAL_WORKER_PROMPT = _role_prompt("external_retrieval_worker.md")
-KNOWLEDGE_RETRIEVAL_WORKER_PROMPT = _role_prompt("knowledge_retrieval_worker.md")
+EVIDENCE_RETRIEVAL_WORKER_PROMPT = _read("evidence_retrieval_worker.md")
 DIRECTION_WORKER_PROMPT = _role_prompt("direction_worker.md")
 RETRIEVAL_VALIDATOR_PROMPT = _role_prompt("retrieval_validator.md")
 SCRIPT_CANDIDATE_VALIDATOR_PROMPT = _role_prompt("candidate_validator.md")
@@ -57,28 +54,10 @@ def script_build_prompt_manifest() -> tuple[dict[str, object], ...]:
             DIRECTION_WORKER_PROMPT,
             1,
         ),
-        "script_pattern_retrieval_worker": (
+        "script_evidence_retrieval_worker": (
             "worker",
-            "pattern_retrieval_worker.md",
-            PATTERN_RETRIEVAL_WORKER_PROMPT,
-            1,
-        ),
-        "script_decode_retrieval_worker": (
-            "worker",
-            "decode_retrieval_worker.md",
-            DECODE_RETRIEVAL_WORKER_PROMPT,
-            1,
-        ),
-        "script_external_retrieval_worker": (
-            "worker",
-            "external_retrieval_worker.md",
-            EXTERNAL_RETRIEVAL_WORKER_PROMPT,
-            1,
-        ),
-        "script_knowledge_retrieval_worker": (
-            "worker",
-            "knowledge_retrieval_worker.md",
-            KNOWLEDGE_RETRIEVAL_WORKER_PROMPT,
+            "evidence_retrieval_worker.md",
+            EVIDENCE_RETRIEVAL_WORKER_PROMPT,
             1,
         ),
         "script_retrieval_validator": (
@@ -177,10 +156,7 @@ def phase_one_prompt_manifest() -> tuple[dict[str, object], ...]:
         "script_context_broker",
         "script_planner",
         "script_direction_worker",
-        "script_pattern_retrieval_worker",
-        "script_decode_retrieval_worker",
-        "script_external_retrieval_worker",
-        "script_knowledge_retrieval_worker",
+        "script_evidence_retrieval_worker",
         "script_retrieval_validator",
         "script_candidate_validator",
     }

+ 31 - 72
script_build_host/src/script_build_host/agents/prompts/script_planner.md

@@ -1,75 +1,34 @@
-你是一个 Script Build Mission 的唯一 Planner。你负责把冻结目标转成可独立执行、可独立验收的
-创作 Task,并根据真实 Validation 持续调整任务图;你不亲自创作内容,也不填写 Host 能确定性
-计算的机械字段
+你是 Script Build Mission 的唯一 Planner。你只表达业务意图、选择已经验收的业务产物,并根据
+Validation 调整工作;Task 的父子关系、scope、输入角色、写权限、执行 preset、预算、证据来源和
+合同 digest 全部由 Host 编译
 
-## 规划协议
+## 工具协议
 
 - 只使用 `plan_script_tasks`、`decide_script_task`、`get_current_context`、
-  `search_mission_context`、`read_mission_context` 和
-  `dispatch_script_tasks`;每次决策前读取对应 Validation。
-- PlannerTaskInput 只提交 `task_kind`、语义 `scope_selector`、`intent_class`、`objective`、
-  `goal_ids`、`criteria` 和 `input_decision_ids`,真实需要时才提交 `base_decision_id`、
-  `comparison_decision_ids`、`supersedes_decision_ids` 或 `gap_key`。`criteria` 使用
-  `client_key`、`criterion_type`、`params`、`hard`;只有非 Retrieval 的 `custom` 标准才写
-  `description`。Retrieval 只允许 `evidence_nonempty`、`source_attributable`、
-  `semantic_relevance`、`concept_coverage`、`freshness`,不能要求映射、人设分析、写作或
-  结构化合成文档。通用硬标准由 Host 注入。
-- Retrieval `params` 必须严格按类型填写:`evidence_nonempty` 只可选
-  `minimum_count`;`source_attributable` 使用空对象;`semantic_relevance` 和
-  `concept_coverage` 必须提供 1–12 个非空字符串的 `concepts`,可选
-  `minimum_matches`;`freshness` 只可选 `max_age_days`。不存在 `query` 参数。
-- 周期 Mission Context 中的 `active_subgraph` 是待推进前沿,`accepted_frontier`
-  是可选 ACCEPT 索引。Decision ID 只能来自该索引。不要提交物理引用、
-  内容摘要值、producer/write scope、schema、budget、candidate closure 或 compose order;
-  这些由 Host 冻结。
-- `active_has_more` 或 `accepted_has_more` 只表示首包有省略,不表示对象不存在。当当前决策
-  真正依赖省略项时,先按 Goal、Task kind 或失败线索过滤,并用 query 写明语义 scope,再用
-  `search_mission_context` 精确查找;不要预防性遍历完整任务树。
-- Phase 1 使用空 `goal_ids`。Phase 2 只使用 active Direction Goal:子 Task 不得超出父 Task
-  Goal 集,兄弟 Task 可以交叉覆盖;Compare 使用候选 Goal 并集,Compose/Portfolio 覆盖全部 Goal。
-- 先创建父 Task 再创建子 Task。阶段层级、Root 所有权和 BLOCK reason 只服从受保护阶段策略。
-- `phase_protocol` 是当前阶段唯一的机器可读协议。只从其中的 `allowed_child_kinds`、
-  `valid_actions` 和 `selectable_decisions` 选择动作与 Decision,不从全局 accepted 列表猜测
-  当前 Compose/Portfolio 的采用闭包。
-- `scope_selector` 只写 `{anchor: mission|parent, path: [语义段...]}`;Host 负责拼接、
-  包含关系和写范围,不得构造 URI。
-- 显式消费已 ACCEPT 的创作产物时,消费者必须留在同一个 Compose 创作分支。
-  Structure、Paragraph、ElementSet 通常是同一 Compose Task 下的兄弟 Task:用 Compose
-  Task ID 作 `parent_task_id`,再把生产者 Decision 放入 `input_decision_ids`。已 completed
-  的生产者 Task 不能再接收子 Task。不要改用 `base_decision_id` 绕过 scope。发生
-  `INPUT_SCOPE_MISMATCH` 时优先按 failure details 中的 `producer_parent_task_id` 和 `recovery`
-  重建正确层级。
-- Phase 2 初始创作 Task 不要把 Phase 1 Retrieval Decision 放入 `input_decision_ids`;active
-  Direction 会由 Host 自动加入,并已闭包其 Retrieval Evidence。只有同一创作分支中确需采用的
-  已 ACCEPT 产物才显式列入输入。
-- 最终可采用的 ElementSet 必须在 Paragraph ACCEPT 后创建或修订,并在
-  `input_decision_ids` 中选择需要建立 Link 的 Paragraph Decision。ElementSet Worker 可用且拥有
-  专用写工具;策略只选 Workbench 给出的 handle。
-
-普通 REVISE 提交一个替代 PlannerTaskInput;SPLIT 提交子 PlannerTaskInput;ACCEPT、RETRY、
-REPAIR、BLOCK、CANCEL 不提交 Task input。Compose 和 CandidatePortfolio 的 REVISE 只提交有序
-`selected_decision_ids`,由 Host 自动补齐 closure、未采用项和 compose order。Compose 必须选择
-已 ACCEPT 的 Structure、Paragraph、ElementSet;Portfolio 只选择一个已 ACCEPT Compose。
-
-Paragraph 可以无 base 探索;`base_decision_id` 只表示修订同类已接受产物,不能用来跨
-类型消费 Structure。替换已
-ACCEPT 产物时才用 `supersedes_decision_ids`。等价 Task 会被拒绝:应继续、修订或处置已有 Task,
-不要用新 ID 制造随机重复候选。
-
-## 创作规划纪律
-
-Goal 是冻结的,完成 Goal 的 Task 图是动态的。每轮按以下顺序思考:观察当前任务树和证据 →
-找出影响最大的未满足 Goal、验收标准或主要缺陷 → 计划最小可验证增量 → 执行 → 验收 → 调整。
-每个阶段只需读取一次冻结输入;后续优先用精简的活动前沿和最新失败,不要重复读取相同快照。
-
-- Task 必须原子化:一个 Task 解决一个清晰缺口,产物能被独立判断,criteria 描述可观察结果。
-- 前期可探索不同的真实路径;证据逐渐充分后必须收敛。只有存在至少两个实质不同且都合理的
-  候选时才创建 Compare,不能为了“多想几个”重复生成同类候选。
-- Validation 失败后先处理一个最主要的缺陷簇。合同或 scope 错误用 REVISE/SPLIT 回到 Planner;
-  内容可在当前 workspace 修好才 REPAIR;同一可重试执行偶发失败才 RETRY。
-- 不得通过改 Goal、改标签或添加空泛说明来掩盖正文缺陷。标签声称“有反转、有感染力”不等于
-  受众真的能从文稿中看到反转或感受到感染力。
-
-Phase 1 必须在 Root 下创建唯一 Direction,在 Direction 下创建并 ACCEPT 所需 Retrieval,再执行
-并 ACCEPT Direction,最后以阶段策略给出的精确 reason BLOCK Root。Retrieval 不能成为 Root 的
-直接子 Task。
+  `search_mission_context`、`read_mission_context` 和 `dispatch_script_tasks`。
+- `plan_script_tasks.intents` 只包含:
+  `task_kind`、`objective`、`decision_ids`、`change_mode`、`target_goal_ids`、
+  `quality_requirements`、`evidence_request`、`within_task_id`。
+- Decision ID 只能来自当前 Context 的可选 ACCEPT 产物。不要提交 URI、parent、scope、
+  criteria 参数、base/comparison/supersession 分类、write scope、preset、tool 或 budget。
+- `change_mode=new` 创建新工作;`derive` 从一个同类 ACCEPT 继续;`replace` 用一个同类
+  ACCEPT 作为被替代版本。Compare 选择至少两个 Decision。
+- 只有 Host 报告存在多个合法容器时才提交 `within_task_id` 消歧,不能自行猜父子关系。
+- `decide_script_task` 只处理 `accept`、`repair`、`retry`、`block`、`cancel`。
+  新候选、拆分和替代都通过新的 intents 表达,不在 decision 中重写合同。
+
+## 阶段纪律
+
+- Phase 1 只提交一个 Direction 业务意图。Host 根据冻结输入生成证据清单,并在需要时自动创建
+  一个 logical evidence-retrieval Task;不要创建或选择 Knowledge、XHS、Pattern 等物理 provider。
+- 只有 Host 报告 hard evidence 已闭合后才 dispatch/ACCEPT Direction。随后用阶段协议给出的精确
+  reason BLOCK Root。
+- Phase 2 只选择 active Direction Goal。每个新的 Structure 会让 Host 原子创建 Portfolio/Compose
+  容器;不要为“开容器”提交空的 Compose 或 CandidatePortfolio。Paragraph、ElementSet 是可独立
+  验收的创作增量;Compose 选择同一分支已经 ACCEPT 的 Structure、Paragraph、ElementSet,
+  CandidatePortfolio 选择一个 ACCEPT Compose。Host 会把选择编译为对应容器的可执行修订。
+- 前期可探索不同的真实路径;进入 Compose 后只收敛已验收产物,不把“多生成”当成质量。
+- Validation 失败时只处理最主要的缺陷簇。当前 workspace 能修复才 `repair`,同一执行的偶发错误
+  才 `retry`;需要新路径、拆分或替换时提交新的 intent。
+- `phase_protocol` 是动作和阶段边界的机器可读事实源。上下文有省略时按 Goal、Task kind 或失败
+  线索精确搜索;`active_has_more` / `accepted_has_more` 只表示首包有省略,不预防性遍历全部历史。

+ 13 - 22
script_build_host/src/script_build_host/application/mission_factory.py

@@ -99,9 +99,9 @@ class ScriptMissionFactory:
                 "context_entrypoint": "get_current_context",
                 "phase_boundary": "PHASE_ONE_CAPABILITY_BOUNDARY",
                 "instruction": (
-                    "Create Direction under Root without dispatching it; create Retrieval "
-                    "children under that Direction; dispatch and ACCEPT Retrieval before "
-                    "dispatching Direction. After Direction ACCEPT, block Root with the exact "
+                    "Submit one business intent for Direction. The Host compiles its contract "
+                    "and creates any required logical evidence work. Dispatch only tasks the "
+                    "Host reports ready. After Direction ACCEPT, block Root with the exact "
                     "reason PHASE_ONE_CAPABILITY_BOUNDARY."
                 ),
             },
@@ -153,25 +153,16 @@ class ScriptMissionFactory:
                 "phase": 2,
                 "context_entrypoint": "get_current_context",
                 "instruction": (
-                    "Create exactly one CandidatePortfolio as Root's Phase2 child. Plan any "
-                    "number and order of Structure, Paragraph, ElementSet, Retrieval and Compare "
-                    "increments demanded by the active Direction Goals; siblings may cover "
-                    "overlapping Goals. Every Phase2 contract has nonempty goal_ids, every "
-                    "creative Task is automatically pinned to the active accepted Direction, "
-                    "and Compose and CandidatePortfolio carry all Direction Goal IDs. "
-                    "Retrieval needs valid goal_ids but need not read the whole Direction. "
-                    "Paragraph-first exploration "
-                    "may use a null base; a Paragraph patch must pin its accepted Structure, and "
-                    "Compose may adopt any Paragraph only with a Structure covering its scope. "
-                    "Compose and Portfolio remain non-executable containers until a REVISE "
-                    "selects accepted child Decision IDs; the Host partitions the complete "
-                    "candidate closure and freezes compose_order. "
-                    "Compose adopts the creative frontier; Portfolio adopts exactly one accepted "
-                    "StructuredScript. Artifact refs, digests, producer scopes, write scopes, "
-                    "schemas, budgets and closure fields are Host-owned. Respect the Host "
-                    "concurrency limit. After the unique Portfolio is validated and ACCEPTed, "
-                    "BLOCK Root "
-                    "with PHASE_TWO_CANDIDATE_PORTFOLIO_READY."
+                    "Submit business intents for Structure, Paragraph, ElementSet and Compare "
+                    "as demanded by the active Direction Goals. Select current ACCEPT Decision "
+                    "IDs only; the Host determines containers, branch identity, semantic targets, "
+                    "workspace seeds, Goal coverage, write claims and execution profiles. "
+                    "A Compose intent selects exactly one accepted Structure, Paragraph and "
+                    "ElementSet from one branch. A CandidatePortfolio intent selects exactly one "
+                    "accepted Compose. Do not create physical Retrieval Tasks or submit contracts, "
+                    "scopes, artifact refs, presets, budgets or closure fields. After the unique "
+                    "Portfolio is validated and ACCEPTed, BLOCK Root with the exact reason "
+                    "PHASE_TWO_CANDIDATE_PORTFOLIO_READY."
                 ),
             },
             ensure_ascii=False,

+ 503 - 0
script_build_host/src/script_build_host/application/task_planning_v2.py

@@ -0,0 +1,503 @@
+"""Thin V2 planning use case around the pure contract compiler."""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Mapping, Sequence
+from hashlib import sha256
+from typing import Any
+
+from agent.orchestration import DecisionAction, FrozenContextDocument, TaskStatus
+
+from script_build_host.domain.artifacts import DirectionArtifact
+from script_build_host.domain.evidence_requirements import (
+    DirectionEvidenceRequirementFactory,
+)
+from script_build_host.domain.phase_two_artifacts import (
+    ParagraphArtifactV1,
+    StructureArtifactV1,
+)
+from script_build_host.domain.task_capabilities import TaskCapabilityCatalog
+from script_build_host.domain.task_contract_compiler import (
+    PlanningContainer,
+    PlanningDecision,
+    PlanningSnapshot,
+    PlanningTarget,
+    TaskContractCompiler,
+    TaskContractMutation,
+)
+from script_build_host.domain.task_contracts import (
+    AcceptedDecisionRef,
+    PlannerTaskIntentV2,
+    ScriptTaskBudget,
+    ScriptTaskContractV2,
+    ScriptTaskKind,
+    TaskContractError,
+)
+from script_build_host.infrastructure.canonical_json import canonical_json_bytes
+
+_CONTRACT_PREFIX = "script-build://task-contracts/sha256/"
+_TASK_KIND_PREFIX = "script-build://task-kinds/"
+_INPUT_PREFIX = "script-build://inputs/"
+_CONTAINER_STATES = {
+    TaskStatus.PENDING,
+    TaskStatus.NEEDS_REPLAN,
+    TaskStatus.WAITING_CHILDREN,
+}
+
+
+class TaskPlanningServiceV2:
+    def __init__(
+        self,
+        *,
+        coordinator: Any,
+        bindings: Any,
+        artifacts: Any,
+        input_snapshots: Any | None = None,
+        compiler: TaskContractCompiler | None = None,
+        budget: ScriptTaskBudget | None = None,
+    ) -> None:
+        self.coordinator = coordinator
+        self.bindings = bindings
+        self.artifacts = artifacts
+        self.input_snapshots = input_snapshots
+        self.compiler = compiler or TaskContractCompiler()
+        self.capabilities = TaskCapabilityCatalog()
+        self.budget = budget or ScriptTaskBudget()
+
+    async def plan(
+        self,
+        *,
+        intents: Sequence[Mapping[str, Any]],
+        context: Mapping[str, Any],
+    ) -> dict[str, Any]:
+        root_trace_id = _required(context, "root_trace_id")
+        command_id = _required(context, "tool_call_id")
+        binding = await self.bindings.get_by_root(root_trace_id)
+        parsed = tuple(PlannerTaskIntentV2.from_payload(item) for item in intents)
+        command_payload = _command_payload(parsed)
+        replay = await self.coordinator.replay_command(
+            root_trace_id,
+            operation="task_graph_created",
+            idempotency_key=command_id,
+            payload=command_payload,
+        )
+        if replay is not None:
+            return await self._result(root_trace_id, tuple(replay["task_ids"]))
+        ledger = await self.coordinator.task_store.load(root_trace_id)
+        snapshot, constraints = await self._snapshot(
+            ledger=ledger,
+            binding=binding,
+            intents=parsed,
+        )
+        compiled = self.compiler.compile_batch(
+            parsed,
+            snapshot,
+            command_id=command_id,
+            budget=self.budget,
+            direction_constraints=constraints,
+        )
+        generated: tuple[Any, ...] = ()
+        directions = tuple(
+            item
+            for item in compiled
+            if item.contract.task_kind is ScriptTaskKind.DIRECTION
+        )
+        if directions:
+            if len(compiled) != 1 or len(directions) != 1:
+                raise TaskContractError(
+                    "TASK_CONTRACT_INVALID",
+                    "Direction planning is one Host-owned atomic subgraph",
+                )
+            direction = directions[0]
+            if any(
+                requirement.satisfied_by_input_ref is None
+                for requirement in direction.contract.evidence_requirements.requirements
+            ):
+                evidence_intent = PlannerTaskIntentV2(
+                    task_kind=ScriptTaskKind.EVIDENCE_RETRIEVAL,
+                    objective="Satisfy the frozen Direction evidence manifest",
+                    within_task_id=direction.task_id,
+                )
+                evidence_snapshot = PlanningSnapshot(
+                    root_trace_id=snapshot.root_trace_id,
+                    phase=snapshot.phase,
+                    containers=(
+                        *snapshot.containers,
+                        PlanningContainer(
+                            direction.task_id,
+                            ScriptTaskKind.DIRECTION,
+                            direction.contract.scope_ref,
+                            direction.contract.branch_ref,
+                            (),
+                        ),
+                    ),
+                    decisions=snapshot.decisions,
+                    targets=snapshot.targets,
+                    evidence_requirements=snapshot.evidence_requirements,
+                )
+                generated = self.compiler.compile_batch(
+                    (evidence_intent,),
+                    evidence_snapshot,
+                    command_id=f"{command_id}:host-evidence",
+                    budget=self.budget,
+                    allow_host_owned=True,
+                )
+        compiled = (*compiled, *generated)
+        documents: list[FrozenContextDocument] = []
+        drafts: list[dict[str, Any]] = []
+        for item in compiled:
+            payload = item.contract.to_payload()
+            content = canonical_json_bytes(payload)
+            digest = sha256(content).hexdigest()
+            uri = f"{_CONTRACT_PREFIX}{digest}"
+            documents.append(
+                FrozenContextDocument(uri, f"sha256:{digest}", payload)
+            )
+            drafts.append(
+                {
+                    "_task_id": item.task_id,
+                    "_parent_task_id": item.parent_task_id,
+                    **(
+                        {"_revision_task_id": item.task_id}
+                        if item.mutation is TaskContractMutation.REVISE
+                        else {}
+                    ),
+                    "objective": item.contract.objective,
+                    "acceptance_criteria": [
+                        criterion.to_payload() for criterion in item.contract.criteria
+                    ],
+                    "context_refs": [
+                        f"{_TASK_KIND_PREFIX}{item.contract.task_kind.value}",
+                        uri,
+                        f"{_INPUT_PREFIX}{binding.input_snapshot_id}",
+                    ],
+                }
+            )
+        result = await self.coordinator.create_task_graph(
+            root_trace_id,
+            drafts,
+            idempotency_key=command_id,
+            context_documents=documents,
+            command_payload=command_payload,
+        )
+        del result
+        return await self._result(
+            root_trace_id, tuple(item.task_id for item in compiled)
+        )
+
+    async def _result(
+        self, root_trace_id: str, task_ids: tuple[str, ...]
+    ) -> dict[str, Any]:
+        ledger = await self.coordinator.task_store.load(root_trace_id)
+        contracts = []
+        for task_id in task_ids:
+            task = ledger.tasks[task_id]
+            contract = self._contract(ledger, task)
+            ref = next(
+                item
+                for item in task.current_spec.context_refs
+                if item.startswith(_CONTRACT_PREFIX)
+            )
+            contracts.append(
+                {
+                    "task_id": task_id,
+                    "task_kind": contract.task_kind.value,
+                    "scope_ref": contract.scope_ref,
+                    "branch_ref": contract.branch_ref,
+                    "contract_digest": ledger.context_documents[ref].digest,
+                }
+            )
+        return {
+            **await self.coordinator.get_tasks(root_trace_id, task_ids),
+            "task_ids": list(task_ids),
+            "contracts": contracts,
+        }
+
+    async def _snapshot(
+        self,
+        *,
+        ledger: Any,
+        binding: Any,
+        intents: tuple[PlannerTaskIntentV2, ...],
+    ) -> tuple[PlanningSnapshot, tuple[Any, ...]]:
+        contracts: dict[str, ScriptTaskContractV2] = {}
+        containers: list[PlanningContainer] = []
+        for task in ledger.tasks.values():
+            if task.status not in _CONTAINER_STATES:
+                continue
+            if task.task_id == ledger.root_task_id:
+                containers.append(
+                    PlanningContainer(task.task_id, None, None, None, ())
+                )
+                continue
+            contract = self._contract(ledger, task)
+            contracts[task.task_id] = contract
+            containers.append(
+                PlanningContainer(
+                    task.task_id,
+                    contract.task_kind,
+                    contract.scope_ref,
+                    contract.branch_ref,
+                    contract.goal_ids,
+                    task.parent_task_id,
+                    task.status is TaskStatus.NEEDS_REPLAN,
+                )
+            )
+        requested_ids = {
+            decision_id for intent in intents for decision_id in intent.decision_ids
+        }
+        decisions: list[PlanningDecision] = []
+        targets: list[PlanningTarget] = []
+        active_direction: AcceptedDecisionRef | None = None
+        direction_artifact: DirectionArtifact | None = None
+        for decision_id in sorted(requested_ids):
+            decision = ledger.decisions.get(decision_id)
+            if decision is None or decision.action is not DecisionAction.ACCEPT:
+                continue
+            resolved, producer = self._accepted_ref(ledger, decision_id, contracts)
+            planning = PlanningDecision(
+                resolved,
+                decision.task_id,
+                producer.branch_ref,
+                producer.goal_ids,
+            )
+            if decision_id in requested_ids:
+                decisions.append(planning)
+                targets.extend(
+                    await self._targets(binding.script_build_id, planning)
+                )
+        if binding.active_direction_artifact_version_id is not None:
+            expected_version = str(binding.active_direction_artifact_version_id)
+            for decision_id, decision in ledger.decisions.items():
+                if decision.action is not DecisionAction.ACCEPT:
+                    continue
+                attempt = ledger.attempts.get(decision.attempt_id or "")
+                if attempt is None or attempt.submission is None:
+                    continue
+                refs = [
+                    *attempt.submission.artifact_refs,
+                    *attempt.submission.evidence_refs,
+                ]
+                if not any(ref.version == expected_version for ref in refs):
+                    continue
+                resolved, _ = self._accepted_ref(ledger, decision_id, contracts)
+                if resolved.expected_task_kind is ScriptTaskKind.DIRECTION:
+                    active_direction = resolved
+                    break
+            version = await self.artifacts.get_by_id(
+                binding.active_direction_artifact_version_id,
+                script_build_id=binding.script_build_id,
+            )
+            if isinstance(version.artifact, DirectionArtifact):
+                direction_artifact = version.artifact
+        goal_ids = (
+            tuple(item.goal_id for item in direction_artifact.goals)
+            if direction_artifact is not None
+            else ()
+        )
+        evidence_requirements = await self._evidence_requirements(
+            binding, intents
+        )
+        return (
+            PlanningSnapshot(
+                ledger.root_trace_id,
+                self._phase_for(intents),
+                tuple(containers),
+                tuple(decisions),
+                tuple(targets),
+                active_direction,
+                goal_ids,
+                evidence_requirements,
+            ),
+            direction_artifact.constraints if direction_artifact else (),
+        )
+
+    async def _evidence_requirements(
+        self,
+        binding: Any,
+        intents: tuple[PlannerTaskIntentV2, ...],
+    ) -> Any:
+        if not any(item.task_kind is ScriptTaskKind.DIRECTION for item in intents):
+            from script_build_host.domain.evidence_requirements import (
+                EvidenceRequirementManifestV1,
+            )
+
+            return EvidenceRequirementManifestV1(())
+        if self.input_snapshots is None:
+            raise TaskContractError(
+                "HOST_CONTRACT_INVARIANT_BROKEN",
+                "Direction planning requires the frozen input snapshot",
+            )
+        snapshot = await self.input_snapshots.get(
+            str(binding.input_snapshot_id),
+            script_build_id=binding.script_build_id,
+        )
+        account_name = str(
+            snapshot.account.get("account_name")
+            or snapshot.account.get("name")
+            or snapshot.account.get("nickname")
+            or ""
+        )
+        factual = tuple(
+            item
+            for intent in intents
+            for item in ((intent.evidence_request or "").strip(),)
+            if item
+        )
+        input_ref = f"script-build://inputs/{snapshot.snapshot_id}"
+        return DirectionEvidenceRequirementFactory().compile(
+            topic_input_ref=input_ref,
+            requested_account_name=account_name,
+            persona_points=tuple(snapshot.persona_points),
+            persona_input_ref=input_ref if snapshot.persona_points else None,
+            section_patterns=tuple(snapshot.section_patterns),
+            section_pattern_input_ref=(
+                input_ref if snapshot.section_patterns else None
+            ),
+            factual_support_requested=bool(factual),
+            factual_required_concepts=factual,
+        )
+
+    def _phase_for(self, intents: tuple[PlannerTaskIntentV2, ...]) -> int:
+        phases = {self.capabilities.get(item.task_kind).phase for item in intents}
+        if len(phases) != 1:
+            raise TaskContractError(
+                "PHASE_POLICY_VIOLATION", "one planning batch cannot span phases"
+            )
+        return next(iter(phases))
+
+    def _contract(
+        self, ledger: Any, task: Any
+    ) -> ScriptTaskContractV2:
+        refs = [
+            item
+            for item in task.current_spec.context_refs
+            if item.startswith(_CONTRACT_PREFIX)
+        ]
+        if len(refs) != 1:
+            raise TaskContractError(
+                "TASK_CONTRACT_NOT_FOUND", f"Task {task.task_id} has no unique contract"
+            )
+        document = ledger.context_documents.get(refs[0])
+        if document is None:
+            raise TaskContractError(
+                "TASK_CONTRACT_NOT_FOUND",
+                "V2 execution requires its atomically frozen Ledger document",
+            )
+        return ScriptTaskContractV2.from_payload(document.payload)
+
+    def _accepted_ref(
+        self,
+        ledger: Any,
+        decision_id: str,
+        contracts: dict[str, ScriptTaskContractV2],
+    ) -> tuple[AcceptedDecisionRef, ScriptTaskContractV2]:
+        decision = ledger.decisions[decision_id]
+        task = ledger.tasks.get(decision.task_id)
+        attempt = ledger.attempts.get(decision.attempt_id or "")
+        validation = ledger.validations.get(decision.validation_id or "")
+        if (
+            task is None
+            or task.status is not TaskStatus.COMPLETED
+            or not task.decision_ids
+            or task.decision_ids[-1] != decision_id
+            or attempt is None
+            or attempt.submission is None
+            or validation is None
+            or getattr(validation.verdict, "value", None) != "passed"
+        ):
+            raise TaskContractError(
+                "CHILD_DECISION_INVALID", f"Decision {decision_id} is stale"
+            )
+        refs = [*attempt.submission.artifact_refs, *attempt.submission.evidence_refs]
+        if len(refs) != 1:
+            raise TaskContractError(
+                "CHILD_DECISION_INVALID", f"Decision {decision_id} owns {len(refs)} artifacts"
+            )
+        producer = contracts.get(task.task_id) or self._contract(ledger, task)
+        contracts[task.task_id] = producer
+        return (
+            AcceptedDecisionRef(
+                decision_id,
+                refs[0],
+                producer.scope_ref,
+                producer.task_kind,
+            ),
+            producer,
+        )
+
+    async def _targets(
+        self, script_build_id: int, decision: PlanningDecision
+    ) -> tuple[PlanningTarget, ...]:
+        version = await self.artifacts.get_by_id(
+            int(decision.ref.artifact_ref.version or "0"),
+            script_build_id=script_build_id,
+        )
+        branch = decision.branch_ref
+        if branch is None:
+            return ()
+        artifact = version.artifact
+        if isinstance(artifact, StructureArtifactV1):
+            return tuple(
+                PlanningTarget(
+                    f"script-build://targets/{version.artifact_version_id}/"
+                    f"{paragraph.level}-{paragraph.paragraph_index}",
+                    decision.ref.decision_id,
+                    ScriptTaskKind.STRUCTURE,
+                    branch,
+                    tuple(
+                        str(item)
+                        for item in paragraph.content_range.get("goal_ids", ())
+                    ),
+                )
+                for paragraph in artifact.paragraphs
+                if paragraph.is_active
+            )
+        if isinstance(artifact, ParagraphArtifactV1):
+            return (
+                PlanningTarget(
+                    f"script-build://targets/{version.artifact_version_id}",
+                    decision.ref.decision_id,
+                    ScriptTaskKind.PARAGRAPH,
+                    branch,
+                    artifact.lineage.goal_ids,
+                ),
+            )
+        return ()
+
+
+def _required(context: Mapping[str, Any], key: str) -> str:
+    value = context.get(key)
+    if not isinstance(value, str) or not value:
+        raise TaskContractError("TASK_CONTRACT_INVALID", f"missing protected {key}")
+    return value
+
+
+def _command_payload(
+    intents: tuple[PlannerTaskIntentV2, ...],
+) -> dict[str, Any]:
+    rows = [
+        {
+            "task_kind": item.task_kind.value,
+            "objective": item.objective,
+            "decision_ids": sorted(item.decision_ids),
+            "change_mode": item.change_mode.value,
+            "target_goal_ids": sorted(item.target_goal_ids),
+            "quality_requirements": sorted(item.quality_requirements),
+            "evidence_request": item.evidence_request,
+            "within_task_id": item.within_task_id,
+        }
+        for item in intents
+    ]
+    return {
+        "intents": sorted(
+            rows,
+            key=lambda item: json.dumps(
+                item, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+            ),
+        )
+    }
+
+
+__all__ = ["TaskPlanningServiceV2"]

+ 7 - 9
script_build_host/src/script_build_host/domain/input_compatibility.py → script_build_host/src/script_build_host/domain/legacy_input_compatibility.py

@@ -1,4 +1,9 @@
-"""Single source of truth for accepted Script Task inputs."""
+"""Read-only V1 accepted-input compatibility.
+
+New V2 contracts obtain input policy exclusively from TaskCapabilityCatalog.
+This module exists only to replay frozen V1 contracts and must not be imported
+by planning or dispatch code.
+"""
 
 from __future__ import annotations
 
@@ -68,15 +73,8 @@ def validate_accepted_input(
     producer_kind: ScriptTaskKind,
     producer_scope: str,
     source: AcceptedInputSource,
-    same_parent: bool = False,
 ) -> None:
-    placement = (
-        source is AcceptedInputSource.EXPLICIT
-        and same_parent
-        and consumer_kind in _CREATIVE
-        and producer_kind in _CREATIVE
-    )
-    if not scopes_compatible(consumer_scope, producer_scope) and not placement:
+    if not scopes_compatible(consumer_scope, producer_scope):
         raise AcceptedInputCompatibilityError("accepted input is outside the consumer scope")
     allowed = (
         _DIRECT_ALLOWED.get(consumer_kind, frozenset())

+ 556 - 87
script_build_host/src/script_build_host/domain/task_capabilities.py

@@ -1,20 +1,21 @@
-"""Single source of truth for Script Task execution capabilities.
-
-The Planner owns business intent.  The Host owns the executable envelope:
-worker, output schema, tools, criterion vocabulary, write ownership and child
-topology.  Keeping those facts together makes impossible contracts fail before
-an Agent or an external dependency is started.
-"""
+"""The only executable-policy catalog for script Tasks."""
 
 from __future__ import annotations
 
+import json
 from collections.abc import Mapping
 from dataclasses import dataclass
 from enum import StrEnum
+from hashlib import sha256
 from types import MappingProxyType
 from typing import Any
 
-from .task_contracts import PlannerCriterionInput, ScriptTaskKind, TaskContractError
+from .task_contracts import (
+    PlannerCriterionInput,
+    ScriptCriterion,
+    ScriptTaskKind,
+    TaskContractError,
+)
 
 
 class CriterionType(StrEnum):
@@ -32,6 +33,26 @@ class CompletionMode(StrEnum):
     HOST_ASSEMBLES = "host_assembles"
 
 
+class BranchPolicy(StrEnum):
+    ROOT_NEW = "root_new"
+    INHERIT_OR_NEW = "inherit_or_new"
+    INHERIT = "inherit"
+    AGGREGATE = "aggregate"
+
+
+class SemanticTargetPolicy(StrEnum):
+    NONE = "none"
+    GOAL_UNIT = "goal_unit"
+    INPUT_DECISION = "input_decision"
+
+
+class GoalPolicy(StrEnum):
+    NONE = "none"
+    SELECTED_OR_PARENT = "selected_or_parent"
+    UNION_INPUTS = "union_inputs"
+    ALL_DIRECTION = "all_direction"
+
+
 @dataclass(frozen=True, slots=True)
 class TaskCapability:
     task_kind: ScriptTaskKind
@@ -42,9 +63,127 @@ class TaskCapability:
     criterion_types: frozenset[CriterionType]
     write_scope: tuple[str, ...]
     child_kinds: frozenset[ScriptTaskKind]
+    input_kinds: frozenset[ScriptTaskKind] = frozenset()
     completion_mode: CompletionMode = CompletionMode.WORKER_SUBMITS
+    phase: int = 2
+    root_task: bool = False
+    requires_direction_input: bool = True
+    goal_policy: GoalPolicy = GoalPolicy.SELECTED_OR_PARENT
+    branch_policy: BranchPolicy = BranchPolicy.INHERIT_OR_NEW
+    semantic_target_policy: SemanticTargetPolicy = SemanticTargetPolicy.NONE
+    workspace_seed_kind: ScriptTaskKind | None = None
+    claim_workspace_lane: bool = False
+    cross_branch_input_kinds: frozenset[ScriptTaskKind] = frozenset()
+    required_adoption_kinds: frozenset[ScriptTaskKind] = frozenset()
+    fixed_criteria: tuple[ScriptCriterion, ...] = ()
+    default_intent_class: str = "expand"
+    planner_creatable: bool = True
+    managed_container: bool = False
+    phase_scaffold: bool = False
+    opens_container_kind: ScriptTaskKind | None = None
+    minimum_selected_decisions: int = 0
+    maximum_selected_decisions: int | None = None
+    exactly_one_per_adoption_kind: bool = False
+    comparison_reference_kinds: frozenset[ScriptTaskKind] = frozenset()
+    artifact_kind: str = ""
+    context_source_types: tuple[str, ...] = ("topic",)
+    persona_limit: int = 16
+    validation_layer: str = "phase-one"
+    preflight_rules: tuple[str, ...] = ()
+    evidence_retrieval: bool = False
+
+    @property
+    def validator_allowed_tools(self) -> frozenset[str]:
+        return _VALIDATOR_TOOLS_BY_PRESET[self.validator_preset]
+
+    def canonical_payload(self) -> dict[str, Any]:
+        return {
+            "task_kind": self.task_kind.value,
+            "output_schemas": sorted(self.output_schemas),
+            "worker_preset": self.worker_preset,
+            "validator_preset": self.validator_preset,
+            "allowed_tools": sorted(self.allowed_tools),
+            "validator_allowed_tools": sorted(self.validator_allowed_tools),
+            "criterion_types": sorted(item.value for item in self.criterion_types),
+            "write_scope": list(self.write_scope),
+            "child_kinds": sorted(item.value for item in self.child_kinds),
+            "input_kinds": sorted(item.value for item in self.input_kinds),
+            "completion_mode": self.completion_mode.value,
+            "phase": self.phase,
+            "root_task": self.root_task,
+            "requires_direction_input": self.requires_direction_input,
+            "goal_policy": self.goal_policy.value,
+            "branch_policy": self.branch_policy.value,
+            "semantic_target_policy": self.semantic_target_policy.value,
+            "workspace_seed_kind": (
+                self.workspace_seed_kind.value if self.workspace_seed_kind else None
+            ),
+            "claim_workspace_lane": self.claim_workspace_lane,
+            "cross_branch_input_kinds": sorted(
+                item.value for item in self.cross_branch_input_kinds
+            ),
+            "required_adoption_kinds": sorted(
+                item.value for item in self.required_adoption_kinds
+            ),
+            "fixed_criteria": [item.to_payload() for item in self.fixed_criteria],
+            "default_intent_class": self.default_intent_class,
+            "planner_creatable": self.planner_creatable,
+            "managed_container": self.managed_container,
+            "phase_scaffold": self.phase_scaffold,
+            "opens_container_kind": (
+                self.opens_container_kind.value
+                if self.opens_container_kind is not None
+                else None
+            ),
+            "minimum_selected_decisions": self.minimum_selected_decisions,
+            "maximum_selected_decisions": self.maximum_selected_decisions,
+            "exactly_one_per_adoption_kind": self.exactly_one_per_adoption_kind,
+            "comparison_reference_kinds": sorted(
+                item.value for item in self.comparison_reference_kinds
+            ),
+            "artifact_kind": self.artifact_kind,
+            "context_source_types": list(self.context_source_types),
+            "persona_limit": self.persona_limit,
+            "validation_layer": self.validation_layer,
+            "preflight_rules": list(self.preflight_rules),
+            "evidence_retrieval": self.evidence_retrieval,
+        }
+
+    def output_schema(self, *, patched: bool = False) -> str:
+        """Choose one schema without knowing a business TaskKind."""
+
+        candidates = sorted(
+            item
+            for item in self.output_schemas
+            if ("-patch/" in item) is patched
+        )
+        if len(candidates) != 1:
+            raise TaskContractError(
+                "HOST_CONTRACT_INVARIANT_BROKEN",
+                f"{self.task_kind.value} has no unique "
+                f"{'patch' if patched else 'primary'} output schema",
+            )
+        return candidates[0]
 
 
+_CONTEXT_READS = frozenset({"search_mission_context", "read_mission_context"})
+_VALIDATOR_TOOLS = frozenset(
+    {
+        *_CONTEXT_READS,
+        "query_validation_evidence",
+        "deterministic_precheck",
+        "submit_validation",
+    }
+)
+_VALIDATOR_TOOLS_BY_PRESET: Mapping[str, frozenset[str]] = MappingProxyType(
+    {
+        "script_retrieval_validator": _VALIDATOR_TOOLS,
+        "script_candidate_validator": frozenset(
+            {*_VALIDATOR_TOOLS, "view_frozen_images"}
+        ),
+        "script_root_validator": _VALIDATOR_TOOLS,
+    }
+)
 _RETRIEVAL_CRITERIA = frozenset(
     {
         CriterionType.EVIDENCE_NONEMPTY,
@@ -55,41 +194,101 @@ _RETRIEVAL_CRITERIA = frozenset(
     }
 )
 _CUSTOM = frozenset({CriterionType.CUSTOM})
-_RETRIEVAL_KINDS = frozenset(
-    {
-        ScriptTaskKind.PATTERN_RETRIEVAL,
-        ScriptTaskKind.DECODE_RETRIEVAL,
-        ScriptTaskKind.EXTERNAL_RETRIEVAL,
-        ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
-    }
-)
-_LOCAL_CHILDREN = frozenset(
+_CREATIVE_CHILDREN = frozenset(
     {
         ScriptTaskKind.STRUCTURE,
         ScriptTaskKind.PARAGRAPH,
         ScriptTaskKind.ELEMENT_SET,
-        ScriptTaskKind.COMPARE,
-        *_RETRIEVAL_KINDS,
     }
 )
-_CONTEXT_READS = frozenset({"search_mission_context", "read_mission_context"})
 
 
-def _retrieval(
-    kind: ScriptTaskKind,
-    preset: str,
-    tool: str,
+def _fixed(identifier: str, description: str) -> tuple[ScriptCriterion, ...]:
+    return (ScriptCriterion(identifier, description, True),)
+
+
+_FIXED: Mapping[ScriptTaskKind, tuple[ScriptCriterion, ...]] = MappingProxyType(
+    {
+        ScriptTaskKind.DIRECTION: _fixed(
+            "direction-coherent", "Direction goals and hard constraints are coherent"
+        ),
+        ScriptTaskKind.EVIDENCE_RETRIEVAL: _fixed(
+            "evidence-usable", "Retrieval returns usable and attributable evidence"
+        ),
+        ScriptTaskKind.PATTERN_RETRIEVAL: _fixed(
+            "evidence-usable", "Retrieval returns usable and attributable evidence"
+        ),
+        ScriptTaskKind.DECODE_RETRIEVAL: _fixed(
+            "evidence-usable", "Retrieval returns usable and attributable evidence"
+        ),
+        ScriptTaskKind.EXTERNAL_RETRIEVAL: _fixed(
+            "evidence-usable", "Retrieval returns usable and attributable evidence"
+        ),
+        ScriptTaskKind.KNOWLEDGE_RETRIEVAL: _fixed(
+            "evidence-usable", "Retrieval returns usable and attributable evidence"
+        ),
+        ScriptTaskKind.STRUCTURE: _fixed(
+            "structure-complete", "Structure covers its declared content scope"
+        ),
+        ScriptTaskKind.PARAGRAPH: _fixed(
+            "paragraph-content-complete", "Paragraph contains complete publishable prose"
+        ),
+        ScriptTaskKind.ELEMENT_SET: _fixed(
+            "elements-linked", "Every Element is valid and linked to a Paragraph"
+        ),
+        ScriptTaskKind.COMPARE: _fixed(
+            "comparison-grounded", "Recommendation is grounded in the frozen candidates"
+        ),
+        ScriptTaskKind.COMPOSE: (
+            ScriptCriterion(
+                "script-content-complete",
+                "Composed script satisfies the complete content contract",
+                True,
+            ),
+            ScriptCriterion(
+                "direction-goals-satisfied",
+                "All Direction goals have real creative coverage",
+                True,
+            ),
+        ),
+        ScriptTaskKind.CANDIDATE_PORTFOLIO: (
+            ScriptCriterion(
+                "portfolio-closed",
+                "Portfolio closes and adopts exactly one valid script",
+                True,
+            ),
+            ScriptCriterion(
+                "direction-goals-satisfied",
+                "All Direction goals have real creative coverage",
+                True,
+            ),
+        ),
+        ScriptTaskKind.ROOT_DELIVERY: (),
+    }
+)
+
+
+def _legacy_retrieval(
+    kind: ScriptTaskKind, preset: str, tool: str
 ) -> TaskCapability:
     return TaskCapability(
-        task_kind=kind,
-        output_schemas=frozenset({"evidence-record/v1"}),
-        worker_preset=preset,
-        validator_preset="script_retrieval_validator",
-        allowed_tools=frozenset({*_CONTEXT_READS, tool, "submit_attempt"}),
-        criterion_types=_RETRIEVAL_CRITERIA,
-        write_scope=(),
-        child_kinds=frozenset(),
-        completion_mode=CompletionMode.RETRIEVAL_AUTO_SUBMIT,
+        kind,
+        frozenset({"evidence-record/v1"}),
+        preset,
+        "script_retrieval_validator",
+        frozenset({*_CONTEXT_READS, tool, "submit_attempt"}),
+        _RETRIEVAL_CRITERIA,
+        (),
+        frozenset(),
+        frozenset(),
+        CompletionMode.RETRIEVAL_AUTO_SUBMIT,
+        phase=1,
+        requires_direction_input=False,
+        goal_policy=GoalPolicy.NONE,
+            branch_policy=BranchPolicy.INHERIT,
+            fixed_criteria=_FIXED[kind],
+            artifact_kind="evidence",
+            validation_layer="legacy",
     )
 
 
@@ -103,24 +302,57 @@ _CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
             frozenset({*_CONTEXT_READS, "save_direction_candidate", "submit_attempt"}),
             _CUSTOM,
             (),
-            _RETRIEVAL_KINDS,
+            frozenset({ScriptTaskKind.EVIDENCE_RETRIEVAL}),
+            frozenset(),
+            phase=1,
+            root_task=True,
+            requires_direction_input=False,
+            goal_policy=GoalPolicy.NONE,
+            branch_policy=BranchPolicy.ROOT_NEW,
+            fixed_criteria=_FIXED[ScriptTaskKind.DIRECTION],
+            default_intent_class="explore",
+            artifact_kind="direction",
+            context_source_types=("topic", "persona", "section_pattern", "strategy"),
+            persona_limit=32,
+        ),
+        ScriptTaskKind.EVIDENCE_RETRIEVAL: TaskCapability(
+            ScriptTaskKind.EVIDENCE_RETRIEVAL,
+            frozenset({"evidence-record/v1"}),
+            "script_evidence_retrieval_worker",
+            "script_retrieval_validator",
+            frozenset({*_CONTEXT_READS, "submit_attempt"}),
+            _RETRIEVAL_CRITERIA,
+            (),
+            frozenset(),
+            frozenset(),
+            CompletionMode.HOST_ASSEMBLES,
+            phase=1,
+            requires_direction_input=False,
+            goal_policy=GoalPolicy.NONE,
+            branch_policy=BranchPolicy.INHERIT,
+            fixed_criteria=_FIXED[ScriptTaskKind.EVIDENCE_RETRIEVAL],
+            planner_creatable=False,
+            artifact_kind="evidence",
+            context_source_types=("topic", "persona"),
+            validation_layer="phase-one",
+            evidence_retrieval=True,
         ),
-        ScriptTaskKind.PATTERN_RETRIEVAL: _retrieval(
+        ScriptTaskKind.PATTERN_RETRIEVAL: _legacy_retrieval(
             ScriptTaskKind.PATTERN_RETRIEVAL,
             "script_pattern_retrieval_worker",
             "query_pattern_qa",
         ),
-        ScriptTaskKind.DECODE_RETRIEVAL: _retrieval(
+        ScriptTaskKind.DECODE_RETRIEVAL: _legacy_retrieval(
             ScriptTaskKind.DECODE_RETRIEVAL,
             "script_decode_retrieval_worker",
             "search_script_decode_case",
         ),
-        ScriptTaskKind.EXTERNAL_RETRIEVAL: _retrieval(
+        ScriptTaskKind.EXTERNAL_RETRIEVAL: _legacy_retrieval(
             ScriptTaskKind.EXTERNAL_RETRIEVAL,
             "script_external_retrieval_worker",
             "external_search_case",
         ),
-        ScriptTaskKind.KNOWLEDGE_RETRIEVAL: _retrieval(
+        ScriptTaskKind.KNOWLEDGE_RETRIEVAL: _legacy_retrieval(
             ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
             "script_knowledge_retrieval_worker",
             "search_knowledge",
@@ -135,7 +367,21 @@ _CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
             ),
             _CUSTOM,
             ("script-build://writes/paragraphs",),
-            _LOCAL_CHILDREN,
+            frozenset(),
+            frozenset({ScriptTaskKind.DIRECTION, ScriptTaskKind.STRUCTURE}),
+            branch_policy=BranchPolicy.INHERIT,
+            claim_workspace_lane=True,
+            fixed_criteria=_FIXED[ScriptTaskKind.STRUCTURE],
+            opens_container_kind=ScriptTaskKind.COMPOSE,
+            artifact_kind="structure",
+            context_source_types=("direction", "section_pattern"),
+            persona_limit=24,
+            validation_layer="local",
+            preflight_rules=(
+                "business-artifact-owner",
+                "realized-content",
+                "candidate-lineage",
+            ),
         ),
         ScriptTaskKind.PARAGRAPH: TaskCapability(
             ScriptTaskKind.PARAGRAPH,
@@ -147,7 +393,28 @@ _CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
             ),
             _CUSTOM,
             ("script-build://writes/paragraphs",),
-            _LOCAL_CHILDREN,
+            frozenset(),
+            frozenset(
+                {
+                    ScriptTaskKind.DIRECTION,
+                    ScriptTaskKind.STRUCTURE,
+                    ScriptTaskKind.PARAGRAPH,
+                }
+            ),
+            branch_policy=BranchPolicy.INHERIT,
+            semantic_target_policy=SemanticTargetPolicy.GOAL_UNIT,
+            workspace_seed_kind=ScriptTaskKind.STRUCTURE,
+            claim_workspace_lane=True,
+            fixed_criteria=_FIXED[ScriptTaskKind.PARAGRAPH],
+            artifact_kind="paragraph",
+            context_source_types=("direction", "structure", "persona"),
+            persona_limit=12,
+            validation_layer="local",
+            preflight_rules=(
+                "business-artifact-owner",
+                "realized-content",
+                "candidate-lineage",
+            ),
         ),
         ScriptTaskKind.ELEMENT_SET: TaskCapability(
             ScriptTaskKind.ELEMENT_SET,
@@ -159,7 +426,28 @@ _CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
             ),
             _CUSTOM,
             ("script-build://writes/elements",),
-            _LOCAL_CHILDREN,
+            frozenset(),
+            frozenset(
+                {
+                    ScriptTaskKind.DIRECTION,
+                    ScriptTaskKind.PARAGRAPH,
+                    ScriptTaskKind.ELEMENT_SET,
+                }
+            ),
+            branch_policy=BranchPolicy.INHERIT,
+            semantic_target_policy=SemanticTargetPolicy.INPUT_DECISION,
+            workspace_seed_kind=ScriptTaskKind.PARAGRAPH,
+            claim_workspace_lane=True,
+            fixed_criteria=_FIXED[ScriptTaskKind.ELEMENT_SET],
+            artifact_kind="element_set",
+            context_source_types=("paragraph",),
+            persona_limit=12,
+            validation_layer="local",
+            preflight_rules=(
+                "business-artifact-owner",
+                "realized-content",
+                "candidate-lineage",
+            ),
         ),
         ScriptTaskKind.COMPARE: TaskCapability(
             ScriptTaskKind.COMPARE,
@@ -169,7 +457,46 @@ _CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
             frozenset({*_CONTEXT_READS, "save_comparison_candidate", "submit_attempt"}),
             _CUSTOM,
             (),
-            _LOCAL_CHILDREN,
+            frozenset(),
+            frozenset(
+                {
+                    ScriptTaskKind.DIRECTION,
+                    ScriptTaskKind.STRUCTURE,
+                    ScriptTaskKind.PARAGRAPH,
+                    ScriptTaskKind.ELEMENT_SET,
+                    ScriptTaskKind.COMPOSE,
+                }
+            ),
+            branch_policy=BranchPolicy.AGGREGATE,
+            goal_policy=GoalPolicy.UNION_INPUTS,
+            cross_branch_input_kinds=frozenset(
+                {
+                    ScriptTaskKind.STRUCTURE,
+                    ScriptTaskKind.PARAGRAPH,
+                    ScriptTaskKind.ELEMENT_SET,
+                    ScriptTaskKind.COMPOSE,
+                }
+            ),
+            fixed_criteria=_FIXED[ScriptTaskKind.COMPARE],
+            default_intent_class="compare",
+            minimum_selected_decisions=2,
+            comparison_reference_kinds=frozenset(
+                {
+                    ScriptTaskKind.STRUCTURE,
+                    ScriptTaskKind.PARAGRAPH,
+                    ScriptTaskKind.ELEMENT_SET,
+                    ScriptTaskKind.COMPOSE,
+                }
+            ),
+            artifact_kind="comparison",
+            context_source_types=(),
+            validation_layer="compare",
+            preflight_rules=(
+                "business-artifact-owner",
+                "realized-content",
+                "candidate-lineage",
+                "comparison-fairness",
+            ),
         ),
         ScriptTaskKind.COMPOSE: TaskCapability(
             ScriptTaskKind.COMPOSE,
@@ -179,8 +506,39 @@ _CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
             frozenset({"submit_attempt"}),
             _CUSTOM,
             ("script-build://writes",),
-            _LOCAL_CHILDREN,
+            _CREATIVE_CHILDREN,
+            frozenset(
+                {
+                    ScriptTaskKind.DIRECTION,
+                    ScriptTaskKind.STRUCTURE,
+                    ScriptTaskKind.PARAGRAPH,
+                    ScriptTaskKind.ELEMENT_SET,
+                    ScriptTaskKind.COMPARE,
+                }
+            ),
             CompletionMode.HOST_ASSEMBLES,
+            goal_policy=GoalPolicy.ALL_DIRECTION,
+            branch_policy=BranchPolicy.INHERIT,
+            required_adoption_kinds=frozenset(
+                {
+                    ScriptTaskKind.STRUCTURE,
+                    ScriptTaskKind.PARAGRAPH,
+                    ScriptTaskKind.ELEMENT_SET,
+                }
+            ),
+            claim_workspace_lane=True,
+            fixed_criteria=_FIXED[ScriptTaskKind.COMPOSE],
+            default_intent_class="compose",
+            managed_container=True,
+            exactly_one_per_adoption_kind=True,
+            comparison_reference_kinds=frozenset({ScriptTaskKind.COMPARE}),
+            artifact_kind="structured_script",
+            validation_layer="global",
+            preflight_rules=(
+                "business-artifact-owner",
+                "realized-content",
+                "goal-coverage",
+            ),
         ),
         ScriptTaskKind.CANDIDATE_PORTFOLIO: TaskCapability(
             ScriptTaskKind.CANDIDATE_PORTFOLIO,
@@ -190,8 +548,27 @@ _CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
             frozenset({"submit_attempt"}),
             _CUSTOM,
             ("script-build://writes",),
-            frozenset({ScriptTaskKind.COMPOSE}),
+            frozenset({ScriptTaskKind.COMPOSE, ScriptTaskKind.COMPARE}),
+            frozenset({ScriptTaskKind.DIRECTION, ScriptTaskKind.COMPOSE}),
             CompletionMode.HOST_ASSEMBLES,
+            root_task=True,
+            goal_policy=GoalPolicy.ALL_DIRECTION,
+            branch_policy=BranchPolicy.AGGREGATE,
+            cross_branch_input_kinds=frozenset({ScriptTaskKind.COMPOSE}),
+            required_adoption_kinds=frozenset({ScriptTaskKind.COMPOSE}),
+            fixed_criteria=_FIXED[ScriptTaskKind.CANDIDATE_PORTFOLIO],
+            default_intent_class="portfolio",
+            managed_container=True,
+            phase_scaffold=True,
+            exactly_one_per_adoption_kind=True,
+            artifact_kind="candidate_portfolio",
+            validation_layer="governance",
+            preflight_rules=(
+                "business-artifact-owner",
+                "realized-content",
+                "input-closure-digest",
+                "goal-coverage",
+            ),
         ),
         ScriptTaskKind.ROOT_DELIVERY: TaskCapability(
             ScriptTaskKind.ROOT_DELIVERY,
@@ -209,47 +586,148 @@ _CAPABILITIES: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
             frozenset(),
             (),
             frozenset(),
+            frozenset(
+                {ScriptTaskKind.DIRECTION, ScriptTaskKind.CANDIDATE_PORTFOLIO}
+            ),
+            phase=3,
+            root_task=True,
+            requires_direction_input=False,
+            goal_policy=GoalPolicy.ALL_DIRECTION,
+            branch_policy=BranchPolicy.AGGREGATE,
+            cross_branch_input_kinds=frozenset(
+                {ScriptTaskKind.DIRECTION, ScriptTaskKind.CANDIDATE_PORTFOLIO}
+            ),
+            fixed_criteria=_FIXED[ScriptTaskKind.ROOT_DELIVERY],
+            default_intent_class="deliver",
+            planner_creatable=False,
+            artifact_kind="root_delivery_manifest",
+            context_source_types=(
+                "direction",
+                "candidate-portfolio",
+                "structured-script",
+            ),
+            validation_layer="root-delivery",
+            preflight_rules=(
+                "root-manifest-shape",
+                "root-blocking-defects-empty",
+                "root-input-closure",
+                "root-script-content",
+                "root-goal-coverage",
+                "root-direction-contract",
+                "root-legacy-projection",
+            ),
         ),
     }
 )
 
 
 def task_capability(kind: ScriptTaskKind) -> TaskCapability:
-    return _CAPABILITIES[kind]
+    return TaskCapabilityCatalog().get(kind)
 
 
 def task_capabilities() -> tuple[TaskCapability, ...]:
-    return tuple(_CAPABILITIES[kind] for kind in ScriptTaskKind)
+    return TaskCapabilityCatalog().all()
+
+
+class TaskCapabilityCatalog:
+    """Executable V2 catalog; physical providers are legacy-only."""
+
+    def __init__(self) -> None:
+        legacy_only = {
+            ScriptTaskKind.PATTERN_RETRIEVAL,
+            ScriptTaskKind.DECODE_RETRIEVAL,
+            ScriptTaskKind.EXTERNAL_RETRIEVAL,
+            ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
+        }
+        self._capabilities: Mapping[ScriptTaskKind, TaskCapability] = MappingProxyType(
+            {
+                kind: capability
+                for kind, capability in _CAPABILITIES.items()
+                if kind not in legacy_only
+            }
+        )
+
+    def get(self, kind: ScriptTaskKind) -> TaskCapability:
+        try:
+            return self._capabilities[kind]
+        except KeyError as exc:
+            raise TaskContractError(
+                "TASK_CAPABILITY_MISMATCH",
+                f"{kind.value} is not executable through the V2 catalog",
+            ) from exc
+
+    def all(self) -> tuple[TaskCapability, ...]:
+        return tuple(
+            self._capabilities[kind]
+            for kind in sorted(self._capabilities, key=lambda item: item.value)
+        )
+
+    def root_kinds(self, phase: int) -> frozenset[ScriptTaskKind]:
+        return frozenset(
+            item.task_kind
+            for item in self._capabilities.values()
+            if item.phase == phase and item.root_task
+        )
+
+    def kinds_for_phase(self, phase: int) -> frozenset[ScriptTaskKind]:
+        return frozenset(
+            item.task_kind
+            for item in self._capabilities.values()
+            if item.phase == phase
+        )
+
+    @property
+    def digest(self) -> str:
+        encoded = json.dumps(
+            [
+                self._capabilities[kind].canonical_payload()
+                for kind in sorted(self._capabilities, key=lambda item: item.value)
+            ],
+            sort_keys=True,
+            separators=(",", ":"),
+        ).encode()
+        return "sha256:" + sha256(encoded).hexdigest()
+
+    def allows_input(
+        self, consumer: ScriptTaskKind, producer: ScriptTaskKind, *, same_branch: bool
+    ) -> bool:
+        capability = self.get(consumer)
+        if producer not in capability.input_kinds:
+            return False
+        return (
+            producer is ScriptTaskKind.DIRECTION
+            or same_branch
+            or producer in capability.cross_branch_input_kinds
+        )
+
+
+class LegacyTaskCapabilityCatalog(TaskCapabilityCatalog):
+    """Read-only V1 compatibility catalog; never used by the V2 compiler."""
+
+    def __init__(self) -> None:
+        self._capabilities = _CAPABILITIES
 
 
 def validate_business_criteria(
     kind: ScriptTaskKind,
     criteria: tuple[PlannerCriterionInput, ...],
+    *,
+    capabilities: TaskCapabilityCatalog | None = None,
 ) -> None:
-    capability = task_capability(kind)
+    allowed = (capabilities or TaskCapabilityCatalog()).get(kind).criterion_types
     invalid: list[str] = []
     for item in criteria:
         try:
             criterion_type = CriterionType(item.criterion_type)
         except ValueError:
-            invalid.append(str(item.criterion_type))
+            invalid.append(item.criterion_type)
             continue
-        if criterion_type not in capability.criterion_types:
+        if criterion_type not in allowed:
             invalid.append(criterion_type.value)
-    invalid = sorted(set(invalid))
     if invalid:
         raise TaskContractError(
             "TASK_CAPABILITY_MISMATCH",
-            f"{kind.value} cannot produce or validate criteria types {invalid}",
-            details={
-                "task_kind": kind.value,
-                "output_schemas": sorted(capability.output_schemas),
-                "completion_mode": capability.completion_mode.value,
-                "allowed_criterion_types": sorted(
-                    item.value for item in capability.criterion_types
-                ),
-                "invalid_criterion_types": invalid,
-            },
+            f"{kind.value} cannot validate criteria types {sorted(set(invalid))}",
         )
 
 
@@ -261,10 +739,10 @@ def render_criterion(value: PlannerCriterionInput) -> str:
     if value.description:
         raise TaskContractError(
             "TASK_CAPABILITY_MISMATCH",
-            "typed criteria are rendered by the Host and cannot carry free-text descriptions",
+            "typed criteria cannot carry free-text descriptions",
         )
     if kind is CriterionType.EVIDENCE_NONEMPTY:
-        minimum = _bounded_int(params, "minimum_count", default=1, minimum=1, maximum=20)
+        minimum = _bounded_int(params, "minimum_count", 1, 1, 20)
         _only(params, {"minimum_count"})
         return f"Evidence contains at least {minimum} usable item(s)"
     if kind is CriterionType.SOURCE_ATTRIBUTABLE:
@@ -272,39 +750,25 @@ def render_criterion(value: PlannerCriterionInput) -> str:
         return "Every accepted evidence item retains an attributable source"
     if kind in {CriterionType.SEMANTIC_RELEVANCE, CriterionType.CONCEPT_COVERAGE}:
         concepts = params.get("concepts")
-        if (
-            not isinstance(concepts, list)
-            or not 1 <= len(concepts) <= 12
-            or any(not isinstance(item, str) or not item.strip() for item in concepts)
+        if not isinstance(concepts, list) or not concepts or any(
+            not isinstance(item, str) or not item.strip() for item in concepts
         ):
             raise TaskContractError(
-                "TASK_CAPABILITY_MISMATCH", "typed evidence criterion requires 1-12 concepts"
+                "TASK_CAPABILITY_MISMATCH", "typed criterion requires concepts"
             )
-        minimum = _bounded_int(
-            params,
-            "minimum_matches",
-            default=1,
-            minimum=1,
-            maximum=len(concepts),
-        )
+        minimum = _bounded_int(params, "minimum_matches", 1, 1, len(concepts))
         _only(params, {"concepts", "minimum_matches"})
-        label = "semantically relates to" if kind is CriterionType.SEMANTIC_RELEVANCE else "covers"
-        concept_list = ", ".join(item.strip() for item in concepts)
-        return f"Evidence {label} at least {minimum} of: {concept_list}"
+        verb = "relates to" if kind is CriterionType.SEMANTIC_RELEVANCE else "covers"
+        return f"Evidence {verb} at least {minimum} of: {', '.join(concepts)}"
     if kind is CriterionType.FRESHNESS:
-        days = _bounded_int(params, "max_age_days", default=365, minimum=1, maximum=3650)
+        days = _bounded_int(params, "max_age_days", 365, 1, 3650)
         _only(params, {"max_age_days"})
-        return f"Evidence is no older than {days} day(s) when publication time is available"
-    raise AssertionError(f"unhandled criterion type: {kind}")
+        return f"Evidence is no older than {days} day(s)"
+    raise AssertionError(kind)
 
 
 def _bounded_int(
-    values: Mapping[str, Any],
-    key: str,
-    *,
-    default: int,
-    minimum: int,
-    maximum: int,
+    values: Mapping[str, Any], key: str, default: int, minimum: int, maximum: int
 ) -> int:
     raw = values.get(key, default)
     if isinstance(raw, bool) or not isinstance(raw, int) or not minimum <= raw <= maximum:
@@ -318,14 +782,19 @@ def _only(values: Mapping[str, Any], allowed: set[str]) -> None:
     unknown = sorted(set(values) - allowed)
     if unknown:
         raise TaskContractError(
-            "TASK_CAPABILITY_MISMATCH", f"criterion params contain unknown fields: {unknown}"
+            "TASK_CAPABILITY_MISMATCH", f"criterion contains unknown params: {unknown}"
         )
 
 
 __all__ = [
+    "BranchPolicy",
     "CompletionMode",
     "CriterionType",
+    "GoalPolicy",
+    "LegacyTaskCapabilityCatalog",
+    "SemanticTargetPolicy",
     "TaskCapability",
+    "TaskCapabilityCatalog",
     "render_criterion",
     "task_capabilities",
     "task_capability",

+ 961 - 0
script_build_host/src/script_build_host/domain/task_contract_compiler.py

@@ -0,0 +1,961 @@
+"""Pure compilation from business intent to frozen executable contracts."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field, replace
+from enum import StrEnum
+from hashlib import sha256
+from typing import Any
+
+from .evidence_requirements import EvidenceRequirementManifestV1
+from .task_capabilities import (
+    BranchPolicy,
+    GoalPolicy,
+    SemanticTargetPolicy,
+    TaskCapability,
+    TaskCapabilityCatalog,
+)
+from .task_contracts import (
+    AcceptedDecisionRef,
+    ExecutionProfile,
+    PlannerChangeMode,
+    PlannerCriterionInput,
+    PlannerTaskIntentV2,
+    ResourceClaim,
+    ScriptCriterion,
+    ScriptIntentClass,
+    ScriptTaskBudget,
+    ScriptTaskContractV2,
+    ScriptTaskKind,
+    TaskContractError,
+    WorkspaceSeedDescriptor,
+)
+from .task_policy import TaskPolicyCatalog
+
+
+@dataclass(frozen=True, slots=True)
+class PlanningContainer:
+    task_id: str
+    task_kind: ScriptTaskKind | None
+    scope_ref: str | None
+    branch_ref: str | None
+    goal_ids: tuple[str, ...] = ()
+    parent_task_id: str | None = None
+    revisable: bool = False
+
+
+@dataclass(frozen=True, slots=True)
+class PlanningDecision:
+    ref: AcceptedDecisionRef
+    producer_task_id: str
+    branch_ref: str | None
+    goal_ids: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class PlanningTarget:
+    target_ref: str
+    source_decision_id: str
+    source_kind: ScriptTaskKind
+    branch_ref: str
+    goal_ids: tuple[str, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class PlanningSnapshot:
+    root_trace_id: str
+    phase: int
+    containers: tuple[PlanningContainer, ...]
+    decisions: tuple[PlanningDecision, ...]
+    targets: tuple[PlanningTarget, ...] = ()
+    active_direction_ref: AcceptedDecisionRef | None = None
+    direction_goal_ids: tuple[str, ...] = ()
+    evidence_requirements: EvidenceRequirementManifestV1 = field(
+        default_factory=lambda: EvidenceRequirementManifestV1(())
+    )
+
+    def decision_map(self) -> dict[str, PlanningDecision]:
+        return {item.ref.decision_id: item for item in self.decisions}
+
+
+@dataclass(frozen=True, slots=True)
+class CompiledTask:
+    task_id: str
+    parent_task_id: str
+    contract: ScriptTaskContractV2
+    mutation: TaskContractMutation = field(
+        default_factory=lambda: TaskContractMutation.CREATE
+    )
+
+
+class TaskContractMutation(StrEnum):
+    CREATE = "create"
+    REVISE = "revise"
+
+
+class TaskContractCompiler:
+    """No I/O, no clock and no mutable Ledger access."""
+
+    def __init__(
+        self,
+        capabilities: TaskCapabilityCatalog | None = None,
+        policies: TaskPolicyCatalog | None = None,
+    ) -> None:
+        self.capabilities = capabilities or TaskCapabilityCatalog()
+        self.policies = policies or TaskPolicyCatalog(self.capabilities)
+
+    def compile_batch(
+        self,
+        intents: tuple[PlannerTaskIntentV2, ...],
+        snapshot: PlanningSnapshot,
+        *,
+        command_id: str,
+        budget: ScriptTaskBudget,
+        direction_constraints: tuple[Any, ...] = (),
+        allow_host_owned: bool = False,
+    ) -> tuple[CompiledTask, ...]:
+        if not intents:
+            raise TaskContractError("TASK_CONTRACT_INVALID", "intent batch is empty")
+        keyed = sorted(
+            ((self._intent_digest(item), item) for item in intents),
+            key=lambda item: item[0],
+        )
+        if len({key for key, _ in keyed}) != len(keyed):
+            raise TaskContractError(
+                "TASK_CONTRACT_DUPLICATE", "intent batch contains duplicate business intents"
+            )
+        compiled: list[CompiledTask] = []
+        working = snapshot
+        opening_intents = tuple(
+            intent
+            for _, intent in keyed
+            if self.capabilities.get(intent.task_kind).opens_container_kind
+            is not None
+            and intent.change_mode is PlannerChangeMode.NEW
+        )
+        phase_scaffolds = tuple(
+            item
+            for item in self.capabilities.all()
+            if item.phase == snapshot.phase and item.phase_scaffold
+        )
+        if opening_intents:
+            if len(phase_scaffolds) != 1:
+                raise TaskContractError(
+                    "HOST_CONTRACT_INVARIANT_BROKEN",
+                    "opening work requires one phase scaffold capability",
+                )
+            working, phase_scaffold = self._ensure_phase_scaffold(
+                working,
+                task_kind=phase_scaffolds[0].task_kind,
+                command_id=command_id,
+                budget=budget,
+                direction_constraints=direction_constraints,
+            )
+            if phase_scaffold is not None:
+                compiled.append(phase_scaffold)
+        for key, intent in keyed:
+            task_id = self._task_id(snapshot.root_trace_id, command_id, key)
+            effective = intent
+            capability = self.capabilities.get(intent.task_kind)
+            if (
+                capability.opens_container_kind is not None
+                and intent.change_mode is PlannerChangeMode.NEW
+            ):
+                working, child_scaffold = self._new_child_scaffold(
+                    working,
+                    task_kind=capability.opens_container_kind,
+                    parent=self._unique_container(
+                        working, phase_scaffolds[0].task_kind
+                    ),
+                    task_id=self._task_id(
+                        snapshot.root_trace_id,
+                        command_id,
+                        f"{capability.opens_container_kind.value}-scaffold:{key}",
+                    ),
+                    budget=budget,
+                    direction_constraints=direction_constraints,
+                )
+                compiled.append(child_scaffold)
+                effective = replace(
+                    intent, within_task_id=child_scaffold.task_id
+                )
+            item = self._compile(
+                effective,
+                working,
+                task_id=task_id,
+                budget=budget,
+                direction_constraints=direction_constraints,
+                allow_host_owned=allow_host_owned,
+            )
+            compiled.append(item)
+            if item.mutation is TaskContractMutation.CREATE:
+                working = replace(
+                    working,
+                    containers=(
+                        *working.containers,
+                        PlanningContainer(
+                            item.task_id,
+                            item.contract.task_kind,
+                            item.contract.scope_ref,
+                            item.contract.branch_ref,
+                            item.contract.goal_ids,
+                            item.parent_task_id,
+                        ),
+                    ),
+                )
+        return tuple(compiled)
+
+    def compile_host_contract(
+        self,
+        *,
+        task_id: str,
+        parent_task_id: str,
+        task_kind: ScriptTaskKind,
+        objective: str,
+        scope_ref: str,
+        branch_ref: str,
+        input_decision_refs: tuple[AcceptedDecisionRef, ...],
+        criteria: tuple[ScriptCriterion, ...],
+        budget: ScriptTaskBudget,
+        goal_ids: tuple[str, ...],
+        evidence_requirements: EvidenceRequirementManifestV1 | None = None,
+    ) -> ScriptTaskContractV2:
+        """Compile a Host-owned lifecycle task from the same catalog as Planner work."""
+
+        capability = self.capabilities.get(task_kind)
+        if capability.planner_creatable:
+            raise TaskContractError(
+                "TASK_CAPABILITY_MISMATCH",
+                f"{task_kind.value} is not a Host-owned lifecycle task",
+            )
+        profile = self._profile(capability)
+        intent = PlannerTaskIntentV2(
+            task_kind=task_kind,
+            objective=objective,
+            decision_ids=tuple(
+                sorted(item.decision_id for item in input_decision_refs)
+            ),
+            within_task_id=parent_task_id,
+        )
+        claims = (
+            (
+                ResourceClaim(
+                    f"script-build://resources/workspace/"
+                    f"{branch_ref.removeprefix('script-build://branches/')}"
+                ),
+            )
+            if capability.claim_workspace_lane
+            else ()
+        )
+        return ScriptTaskContractV2(
+            task_kind=task_kind,
+            scope_ref=scope_ref,
+            intent_class=ScriptIntentClass(capability.default_intent_class),
+            objective=objective,
+            input_decision_refs=input_decision_refs,
+            base_artifact_ref=None,
+            write_scope=tuple(
+                f"{item}/{branch_ref.removeprefix('script-build://branches/')}"
+                for item in capability.write_scope
+            ),
+            gap_ref=None,
+            output_schema=capability.output_schema(),
+            criteria=criteria,
+            budget=budget,
+            goal_ids=tuple(sorted(goal_ids)),
+            compiler_manifest_digest=self._manifest_digest(
+                task_id,
+                parent_task_id,
+                intent,
+                branch_ref,
+                None,
+                profile,
+            ),
+            branch_ref=branch_ref,
+            execution_profile=profile,
+            resource_claims=claims,
+            workspace_seed=None,
+            evidence_requirements=(
+                evidence_requirements
+                if evidence_requirements is not None
+                else EvidenceRequirementManifestV1(())
+            ),
+            catalog_digest=self.capabilities.digest,
+        )
+
+    def _compile(
+        self,
+        intent: PlannerTaskIntentV2,
+        snapshot: PlanningSnapshot,
+        *,
+        task_id: str,
+        budget: ScriptTaskBudget,
+        direction_constraints: tuple[Any, ...],
+        allow_host_owned: bool,
+    ) -> CompiledTask:
+        capability = self.capabilities.get(intent.task_kind)
+        if not capability.planner_creatable and not allow_host_owned:
+            raise TaskContractError(
+                "TASK_CAPABILITY_MISMATCH",
+                f"{intent.task_kind.value} is Host-owned",
+            )
+        if capability.phase != snapshot.phase:
+            raise TaskContractError(
+                "PHASE_POLICY_VIOLATION",
+                f"{intent.task_kind.value} does not belong to phase {snapshot.phase}",
+            )
+        selected = self._selected(intent, snapshot)
+        revision_target = self._revision_target(intent, snapshot, selected)
+        if revision_target is not None:
+            task_id = revision_target.task_id
+            parent = self._container(snapshot, revision_target.parent_task_id)
+        else:
+            parent = self._parent(intent, snapshot, selected)
+        if len(selected) < capability.minimum_selected_decisions or (
+            capability.maximum_selected_decisions is not None
+            and len(selected) > capability.maximum_selected_decisions
+        ):
+            raise TaskContractError(
+                "TASK_CONTRACT_INVALID",
+                f"{intent.task_kind.value} selected Decision count is invalid",
+            )
+        branch_ref = (
+            revision_target.branch_ref
+            if revision_target is not None
+            else self._branch(capability, snapshot, parent, selected, task_id)
+        )
+        if branch_ref is None:
+            raise TaskContractError(
+                "HOST_CONTRACT_INVARIANT_BROKEN",
+                f"{intent.task_kind.value} container has no frozen branch",
+            )
+        self._validate_branches(capability, selected, branch_ref)
+        goals = self._goals(capability, intent, snapshot, parent, selected)
+        seed = self._seed(capability, intent, snapshot, selected, branch_ref, goals)
+        scope_ref = self._scope(intent.task_kind, branch_ref, seed)
+        same_kind = tuple(
+            item.ref for item in selected if item.ref.expected_task_kind is intent.task_kind
+        )
+        base = None
+        supersedes: tuple[str, ...] = ()
+        if intent.change_mode is not PlannerChangeMode.NEW:
+            if len(same_kind) != 1:
+                raise TaskContractError(
+                    "TASK_CONTRACT_INVALID",
+                    f"{intent.change_mode.value} requires exactly one same-kind Decision",
+                )
+            base = same_kind[0].artifact_ref
+            scope_ref = same_kind[0].scope_ref
+            if intent.change_mode is PlannerChangeMode.REPLACE:
+                supersedes = (same_kind[0].decision_id,)
+        inputs = [item.ref for item in selected]
+        if capability.requires_direction_input:
+            if snapshot.active_direction_ref is None:
+                raise TaskContractError("GOAL_SCOPE_INVALID", "active Direction is missing")
+            inputs = [
+                item
+                for item in inputs
+                if item.expected_task_kind
+                is not snapshot.active_direction_ref.expected_task_kind
+            ]
+            inputs.insert(0, snapshot.active_direction_ref)
+        candidates = tuple(
+            sorted(
+                (
+                    item.ref
+                    for item in selected
+                    if item.ref.expected_task_kind in capability.required_adoption_kinds
+                ),
+                key=lambda item: item.decision_id,
+            )
+        )
+        selected_kinds = {item.expected_task_kind for item in candidates}
+        missing = capability.required_adoption_kinds - selected_kinds
+        if missing:
+            raise TaskContractError(
+                "TASK_CONTRACT_NOT_EXECUTABLE",
+                "required accepted kinds are missing: "
+                + ",".join(sorted(item.value for item in missing)),
+            )
+        if capability.exactly_one_per_adoption_kind and any(
+            sum(item.expected_task_kind is kind for item in candidates) != 1
+            for kind in capability.required_adoption_kinds
+        ):
+            raise TaskContractError(
+                "TASK_CONTRACT_NOT_EXECUTABLE",
+                "each required accepted kind must appear exactly once",
+            )
+        business = tuple(
+            PlannerCriterionInput(f"quality-{index + 1}", description)
+            for index, description in enumerate(sorted(intent.quality_requirements))
+        )
+        criteria = self.policies.criteria(
+            task_id=task_id,
+            task_kind=intent.task_kind,
+            business=business,
+            direction_constraints=direction_constraints,
+        )
+        claims = (
+            (
+                ResourceClaim(
+                    f"script-build://resources/workspace/"
+                    f"{branch_ref.removeprefix('script-build://branches/')}"
+                ),
+            )
+            if capability.claim_workspace_lane
+            else ()
+        )
+        profile = self._profile(capability)
+        adopted = tuple(item.decision_id for item in candidates)
+        contract = ScriptTaskContractV2(
+            task_kind=intent.task_kind,
+            scope_ref=scope_ref,
+            intent_class=ScriptIntentClass(capability.default_intent_class),
+            objective=intent.objective,
+            input_decision_refs=tuple(inputs),
+            base_artifact_ref=base,
+            write_scope=tuple(
+                f"{item}/{branch_ref.removeprefix('script-build://branches/')}"
+                for item in capability.write_scope
+            ),
+            gap_ref=None,
+            output_schema=capability.output_schema(patched=base is not None),
+            criteria=criteria,
+            budget=budget,
+            goal_ids=goals,
+            supersedes_decision_ids=supersedes,
+            candidate_closure_decision_refs=candidates,
+            adopted_decision_ids=adopted,
+            compose_order=adopted,
+            comparison_decision_refs=(
+                tuple(
+                    item.ref
+                    for item in selected
+                    if item.ref.expected_task_kind
+                    in capability.comparison_reference_kinds
+                )
+            ),
+            compiler_manifest_digest=self._manifest_digest(
+                task_id, parent.task_id, intent, branch_ref, seed, profile
+            ),
+            branch_ref=branch_ref,
+            execution_profile=profile,
+            resource_claims=claims,
+            workspace_seed=seed,
+            evidence_requirements=snapshot.evidence_requirements,
+            catalog_digest=self.capabilities.digest,
+        )
+        return CompiledTask(
+            task_id,
+            parent.task_id,
+            contract,
+            (
+                TaskContractMutation.REVISE
+                if revision_target is not None
+                else TaskContractMutation.CREATE
+            ),
+        )
+
+    def _parent(
+        self,
+        intent: PlannerTaskIntentV2,
+        snapshot: PlanningSnapshot,
+        selected: tuple[PlanningDecision, ...],
+    ) -> PlanningContainer:
+        selected_branches = {
+            item.branch_ref for item in selected if item.branch_ref is not None
+        }
+        candidates = tuple(
+            item
+            for item in snapshot.containers
+            if (
+                item.task_kind is None
+                and self.capabilities.get(intent.task_kind).root_task
+            )
+            or (
+                item.task_kind is not None
+                and intent.task_kind in self.capabilities.get(item.task_kind).child_kinds
+            )
+        )
+        if (
+            len(selected_branches) == 1
+            and self.capabilities.get(intent.task_kind).branch_policy
+            is not BranchPolicy.AGGREGATE
+        ):
+            branch = next(iter(selected_branches))
+            branch_candidates = tuple(
+                item for item in candidates if item.branch_ref == branch
+            )
+            if branch_candidates:
+                candidates = branch_candidates
+        if intent.within_task_id is not None:
+            candidates = tuple(
+                item for item in candidates if item.task_id == intent.within_task_id
+            )
+        if len(candidates) != 1:
+            raise TaskContractError(
+                "TASK_PARENT_AMBIGUOUS",
+                f"Host found {len(candidates)} valid containers for {intent.task_kind.value}",
+            )
+        return candidates[0]
+
+    def _revision_target(
+        self,
+        intent: PlannerTaskIntentV2,
+        snapshot: PlanningSnapshot,
+        selected: tuple[PlanningDecision, ...],
+    ) -> PlanningContainer | None:
+        capability = self.capabilities.get(intent.task_kind)
+        if not capability.managed_container:
+            return None
+        if not selected:
+            raise TaskContractError(
+                "TASK_CONTRACT_NOT_EXECUTABLE",
+                f"{intent.task_kind.value} is a Host-managed container; "
+                "select its accepted closure to make it executable",
+            )
+        candidates = tuple(
+            item
+            for item in snapshot.containers
+            if item.task_kind is intent.task_kind
+        )
+        if capability.branch_policy is BranchPolicy.INHERIT:
+            branches = {
+                item.branch_ref for item in selected if item.branch_ref is not None
+            }
+            if len(branches) != 1:
+                raise TaskContractError(
+                    "INPUT_SCOPE_MISMATCH",
+                    "Compose closure must belong to one creative branch",
+                )
+            branch = next(iter(branches))
+            candidates = tuple(
+                item for item in candidates if item.branch_ref == branch
+            )
+        if intent.within_task_id is not None:
+            candidates = tuple(
+                item for item in candidates if item.task_id == intent.within_task_id
+            )
+        if len(candidates) == 1 and not candidates[0].revisable:
+            raise TaskContractError(
+                "TASK_CONTRACT_NOT_EXECUTABLE",
+                f"{intent.task_kind.value} still has nonterminal child work",
+            )
+        if len(candidates) != 1:
+            raise TaskContractError(
+                "TASK_PARENT_AMBIGUOUS",
+                f"Host found {len(candidates)} open {intent.task_kind.value} containers",
+            )
+        return candidates[0]
+
+    def _ensure_phase_scaffold(
+        self,
+        snapshot: PlanningSnapshot,
+        *,
+        task_kind: ScriptTaskKind,
+        command_id: str,
+        budget: ScriptTaskBudget,
+        direction_constraints: tuple[Any, ...],
+    ) -> tuple[PlanningSnapshot, CompiledTask | None]:
+        existing = tuple(
+            item
+            for item in snapshot.containers
+            if item.task_kind is task_kind
+        )
+        if len(existing) > 1:
+            raise TaskContractError(
+                "HOST_CONTRACT_INVARIANT_BROKEN",
+                f"phase has more than one {task_kind.value} container",
+            )
+        if existing:
+            return snapshot, None
+        root = self._unique_container(snapshot, None)
+        task_id = self._task_id(
+            snapshot.root_trace_id, command_id, f"{task_kind.value}-scaffold"
+        )
+        branch = _branch_ref(snapshot.root_trace_id, task_id)
+        compiled = self._scaffold(
+            task_kind=task_kind,
+            task_id=task_id,
+            parent=root,
+            branch_ref=branch,
+            snapshot=snapshot,
+            budget=budget,
+            direction_constraints=direction_constraints,
+        )
+        return (
+            replace(
+                snapshot,
+                containers=(
+                    *snapshot.containers,
+                    PlanningContainer(
+                        task_id,
+                        compiled.contract.task_kind,
+                        compiled.contract.scope_ref,
+                        branch,
+                        compiled.contract.goal_ids,
+                        root.task_id,
+                    ),
+                ),
+            ),
+            compiled,
+        )
+
+    def _new_child_scaffold(
+        self,
+        snapshot: PlanningSnapshot,
+        *,
+        task_kind: ScriptTaskKind,
+        parent: PlanningContainer,
+        task_id: str,
+        budget: ScriptTaskBudget,
+        direction_constraints: tuple[Any, ...],
+    ) -> tuple[PlanningSnapshot, CompiledTask]:
+        branch = _branch_ref(snapshot.root_trace_id, task_id)
+        compiled = self._scaffold(
+            task_kind=task_kind,
+            task_id=task_id,
+            parent=parent,
+            branch_ref=branch,
+            snapshot=snapshot,
+            budget=budget,
+            direction_constraints=direction_constraints,
+        )
+        return (
+            replace(
+                snapshot,
+                containers=(
+                    *snapshot.containers,
+                    PlanningContainer(
+                        task_id,
+                        compiled.contract.task_kind,
+                        compiled.contract.scope_ref,
+                        branch,
+                        compiled.contract.goal_ids,
+                        parent.task_id,
+                    ),
+                ),
+            ),
+            compiled,
+        )
+
+    def _scaffold(
+        self,
+        *,
+        task_kind: ScriptTaskKind,
+        task_id: str,
+        parent: PlanningContainer,
+        branch_ref: str,
+        snapshot: PlanningSnapshot,
+        budget: ScriptTaskBudget,
+        direction_constraints: tuple[Any, ...],
+    ) -> CompiledTask:
+        capability = self.capabilities.get(task_kind)
+        criteria = self.policies.criteria(
+            task_id=task_id,
+            task_kind=task_kind,
+            business=(),
+            direction_constraints=direction_constraints,
+        )
+        profile = self._profile(capability)
+        direction_inputs = (
+            (snapshot.active_direction_ref,)
+            if capability.requires_direction_input
+            and snapshot.active_direction_ref is not None
+            else ()
+        )
+        if capability.requires_direction_input and not direction_inputs:
+            raise TaskContractError("GOAL_SCOPE_INVALID", "active Direction is missing")
+        objective = f"Host-managed {task_kind.value} container"
+        contract = ScriptTaskContractV2(
+            task_kind=task_kind,
+            scope_ref=self._scope(task_kind, branch_ref, None),
+            intent_class=ScriptIntentClass(capability.default_intent_class),
+            objective=objective,
+            input_decision_refs=direction_inputs,
+            base_artifact_ref=None,
+            write_scope=(),
+            gap_ref=None,
+            output_schema=capability.output_schema(),
+            criteria=criteria,
+            budget=budget,
+            goal_ids=tuple(sorted(snapshot.direction_goal_ids)),
+            compiler_manifest_digest=self._manifest_digest(
+                task_id,
+                parent.task_id,
+                PlannerTaskIntentV2(task_kind, objective),
+                branch_ref,
+                None,
+                profile,
+            ),
+            branch_ref=branch_ref,
+            execution_profile=profile,
+            resource_claims=(),
+            workspace_seed=None,
+            evidence_requirements=snapshot.evidence_requirements,
+            catalog_digest=self.capabilities.digest,
+        )
+        return CompiledTask(task_id, parent.task_id, contract)
+
+    @staticmethod
+    def _container(
+        snapshot: PlanningSnapshot, task_id: str | None
+    ) -> PlanningContainer:
+        matches = tuple(
+            item for item in snapshot.containers if item.task_id == task_id
+        )
+        if len(matches) != 1:
+            raise TaskContractError(
+                "HOST_CONTRACT_INVARIANT_BROKEN",
+                f"Host container {task_id!r} is missing",
+            )
+        return matches[0]
+
+    @staticmethod
+    def _unique_container(
+        snapshot: PlanningSnapshot, kind: ScriptTaskKind | None
+    ) -> PlanningContainer:
+        matches = tuple(
+            item for item in snapshot.containers if item.task_kind is kind
+        )
+        if len(matches) != 1:
+            label = "root" if kind is None else kind.value
+            raise TaskContractError(
+                "TASK_PARENT_AMBIGUOUS",
+                f"Host found {len(matches)} {label} containers",
+            )
+        return matches[0]
+
+    @staticmethod
+    def _selected(
+        intent: PlannerTaskIntentV2, snapshot: PlanningSnapshot
+    ) -> tuple[PlanningDecision, ...]:
+        known = snapshot.decision_map()
+        missing = sorted(set(intent.decision_ids) - set(known))
+        if missing:
+            raise TaskContractError(
+                "CHILD_DECISION_INVALID", f"Decisions are not current ACCEPTs: {missing}"
+            )
+        return tuple(known[item] for item in sorted(intent.decision_ids))
+
+    @staticmethod
+    def _branch(
+        capability: TaskCapability,
+        snapshot: PlanningSnapshot,
+        parent: PlanningContainer,
+        selected: tuple[PlanningDecision, ...],
+        task_id: str,
+    ) -> str:
+        selected_branches = sorted({item.branch_ref for item in selected if item.branch_ref})
+        if capability.branch_policy is BranchPolicy.ROOT_NEW:
+            return _branch_ref(snapshot.root_trace_id, task_id)
+        if capability.branch_policy is BranchPolicy.INHERIT:
+            inherited = parent.branch_ref or (
+                selected_branches[0] if len(selected_branches) == 1 else None
+            )
+            if inherited is None:
+                raise TaskContractError(
+                    "INPUT_SCOPE_MISMATCH", "Task requires one inherited creative branch"
+                )
+            return inherited
+        if capability.branch_policy is BranchPolicy.INHERIT_OR_NEW:
+            return parent.branch_ref or (
+                selected_branches[0]
+                if len(selected_branches) == 1
+                else _branch_ref(snapshot.root_trace_id, task_id)
+            )
+        return parent.branch_ref or _branch_ref(snapshot.root_trace_id, parent.task_id)
+
+    def _validate_branches(
+        self,
+        capability: TaskCapability,
+        selected: tuple[PlanningDecision, ...],
+        branch_ref: str,
+    ) -> None:
+        for item in selected:
+            if not self.capabilities.allows_input(
+                capability.task_kind,
+                item.ref.expected_task_kind,
+                same_branch=item.branch_ref in (None, branch_ref),
+            ):
+                raise TaskContractError(
+                    "INPUT_SCOPE_MISMATCH",
+                    f"{item.ref.decision_id} belongs to a different branch",
+                )
+
+    @staticmethod
+    def _goals(
+        capability: TaskCapability,
+        intent: PlannerTaskIntentV2,
+        snapshot: PlanningSnapshot,
+        parent: PlanningContainer,
+        selected_decisions: tuple[PlanningDecision, ...],
+    ) -> tuple[str, ...]:
+        if capability.goal_policy is GoalPolicy.NONE:
+            if intent.target_goal_ids:
+                raise TaskContractError(
+                    "GOAL_SCOPE_INVALID", "this Task cannot select Direction Goals"
+                )
+            return ()
+        if capability.goal_policy is GoalPolicy.ALL_DIRECTION:
+            selected = snapshot.direction_goal_ids
+            if intent.target_goal_ids and set(intent.target_goal_ids) != set(selected):
+                raise TaskContractError(
+                    "GOAL_SCOPE_INVALID",
+                    "this Task must cover every active Direction Goal",
+                )
+        elif capability.goal_policy is GoalPolicy.UNION_INPUTS:
+            selected = tuple(
+                sorted(
+                    {
+                        goal_id
+                        for decision in selected_decisions
+                        for goal_id in decision.goal_ids
+                    }
+                )
+            )
+            if intent.target_goal_ids and set(intent.target_goal_ids) != set(selected):
+                raise TaskContractError(
+                    "GOAL_SCOPE_INVALID",
+                    "Compare goal_ids must equal the union of selected candidates",
+                )
+        else:
+            selected = (
+                intent.target_goal_ids
+                or parent.goal_ids
+                or snapshot.direction_goal_ids
+            )
+        if not selected or not set(selected) <= set(snapshot.direction_goal_ids):
+            raise TaskContractError(
+                "GOAL_SCOPE_INVALID", "target goals are outside the active Direction"
+            )
+        return tuple(sorted(selected))
+
+    @staticmethod
+    def _seed(
+        capability: TaskCapability,
+        intent: PlannerTaskIntentV2,
+        snapshot: PlanningSnapshot,
+        selected: tuple[PlanningDecision, ...],
+        branch_ref: str,
+        goals: tuple[str, ...],
+    ) -> WorkspaceSeedDescriptor | None:
+        if capability.workspace_seed_kind is None:
+            return None
+        targets = tuple(
+            item
+            for item in snapshot.targets
+            if item.source_kind is capability.workspace_seed_kind
+            and item.branch_ref == branch_ref
+        )
+        if capability.semantic_target_policy is SemanticTargetPolicy.GOAL_UNIT:
+            targets = tuple(item for item in targets if set(item.goal_ids) == set(goals))
+        elif capability.semantic_target_policy is SemanticTargetPolicy.INPUT_DECISION:
+            selected_ids = {item.ref.decision_id for item in selected}
+            targets = tuple(
+                item for item in targets if item.source_decision_id in selected_ids
+            )
+        if len(targets) != 1:
+            raise TaskContractError(
+                "TASK_TARGET_AMBIGUOUS",
+                f"Host found {len(targets)} semantic targets for {intent.task_kind.value}",
+            )
+        target = targets[0]
+        return WorkspaceSeedDescriptor(
+            target.source_kind,
+            target.source_decision_id,
+            target.target_ref,
+        )
+
+    @staticmethod
+    def _scope(
+        task_kind: ScriptTaskKind,
+        branch_ref: str,
+        seed: WorkspaceSeedDescriptor | None,
+    ) -> str:
+        branch = branch_ref.removeprefix("script-build://branches/")
+        base = f"script-build://scopes/{branch}/{task_kind.value}"
+        return (
+            f"{base}/targets/{seed.semantic_target_ref.rsplit('/', 1)[-1]}"
+            if seed
+            else base
+        )
+
+    @staticmethod
+    def _intent_digest(intent: PlannerTaskIntentV2) -> str:
+        payload = {
+            "task_kind": intent.task_kind.value,
+            "objective": intent.objective,
+            "decision_ids": sorted(intent.decision_ids),
+            "change_mode": intent.change_mode.value,
+            "target_goal_ids": sorted(intent.target_goal_ids),
+            "quality_requirements": sorted(intent.quality_requirements),
+            "evidence_request": intent.evidence_request,
+            "within_task_id": intent.within_task_id,
+        }
+        return sha256(
+            json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
+        ).hexdigest()
+
+    @staticmethod
+    def _task_id(root_trace_id: str, command_id: str, intent_digest: str) -> str:
+        digest = sha256(
+            f"{root_trace_id}\0{command_id}\0{intent_digest}".encode()
+        ).hexdigest()
+        return f"task-{digest[:24]}"
+
+    @staticmethod
+    def _capability_digest(capability: TaskCapability) -> str:
+        encoded = json.dumps(
+            capability.canonical_payload(), sort_keys=True, separators=(",", ":")
+        ).encode()
+        return "sha256:" + sha256(encoded).hexdigest()
+
+    def _profile(self, capability: TaskCapability) -> ExecutionProfile:
+        return ExecutionProfile(
+            capability.worker_preset,
+            capability.validator_preset,
+            tuple(sorted(capability.allowed_tools)),
+            tuple(sorted(capability.validator_allowed_tools)),
+            capability.completion_mode.value,
+            self._capability_digest(capability),
+        )
+
+    def _manifest_digest(
+        self,
+        task_id: str,
+        parent_task_id: str,
+        intent: PlannerTaskIntentV2,
+        branch_ref: str,
+        seed: WorkspaceSeedDescriptor | None,
+        profile: ExecutionProfile,
+    ) -> str:
+        payload = {
+            "task_id": task_id,
+            "parent_task_id": parent_task_id,
+            "intent_digest": self._intent_digest(intent),
+            "branch_ref": branch_ref,
+            "workspace_seed": seed.to_payload() if seed else None,
+            "execution_profile": profile.to_payload(),
+            "catalog_digest": self.capabilities.digest,
+        }
+        return "sha256:" + sha256(
+            json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
+        ).hexdigest()
+
+
+def _branch_ref(root_trace_id: str, identity: str) -> str:
+    digest = sha256(f"{root_trace_id}\0{identity}".encode()).hexdigest()[:24]
+    return f"script-build://branches/{digest}"
+
+
+__all__ = [
+    "CompiledTask",
+    "PlanningContainer",
+    "PlanningDecision",
+    "PlanningSnapshot",
+    "PlanningTarget",
+    "TaskContractCompiler",
+    "TaskContractMutation",
+]

+ 248 - 141
script_build_host/src/script_build_host/domain/task_contracts.py

@@ -14,6 +14,7 @@ from agent.orchestration import ArtifactRef
 
 from script_build_host.domain.digests import Sha256Digest
 from script_build_host.domain.errors import ScriptBuildError
+from script_build_host.domain.evidence_requirements import EvidenceRequirementManifestV1
 from script_build_host.domain.goal_coverage import MAX_DIRECTION_GOALS
 
 _CONTROL_URI_PREFIX = "script-build://"
@@ -52,6 +53,7 @@ class TaskContractError(ScriptBuildError):
 
 class ScriptTaskKind(StrEnum):
     DIRECTION = "direction"
+    EVIDENCE_RETRIEVAL = "evidence-retrieval"
     PATTERN_RETRIEVAL = "pattern-retrieval"
     DECODE_RETRIEVAL = "decode-retrieval"
     EXTERNAL_RETRIEVAL = "external-retrieval"
@@ -76,41 +78,10 @@ class ScriptIntentClass(StrEnum):
     DELIVER = "deliver"
 
 
-class ScopeAnchor(StrEnum):
-    MISSION = "mission"
-    PARENT = "parent"
-
-
-@dataclass(frozen=True, slots=True)
-class ScopeSelector:
-    anchor: ScopeAnchor
-    path: tuple[str, ...]
-
-    def __post_init__(self) -> None:
-        if len(self.path) > 4 or (self.anchor is ScopeAnchor.MISSION and not self.path):
-            raise TaskContractError(
-                "TASK_CONTRACT_INVALID", "scope_selector path must contain 1-4 mission segments"
-            )
-        for segment in self.path:
-            _require_identifier(segment, "scope_selector path segment")
-
-    @classmethod
-    def from_payload(cls, value: object) -> ScopeSelector:
-        if not isinstance(value, Mapping):
-            raise TaskContractError("TASK_CONTRACT_INVALID", "scope_selector must be an object")
-        unknown = set(value) - {"anchor", "path"}
-        if unknown:
-            raise TaskContractError(
-                "TASK_CONTRACT_INVALID",
-                f"scope_selector contains unknown fields: {sorted(unknown)}",
-            )
-        return cls(
-            anchor=_enum_value(ScopeAnchor, value.get("anchor"), "scope_selector.anchor"),
-            path=_strings(value.get("path"), "scope_selector.path"),
-        )
-
-    def to_payload(self) -> dict[str, Any]:
-        return {"anchor": self.anchor.value, "path": list(self.path)}
+class PlannerChangeMode(StrEnum):
+    NEW = "new"
+    DERIVE = "derive"
+    REPLACE = "replace"
 
 
 @dataclass(frozen=True, slots=True)
@@ -150,21 +121,6 @@ class PlannerCriterionInput:
         )
 
 
-_OUTPUT_SCHEMAS: dict[ScriptTaskKind, frozenset[str]] = {
-    ScriptTaskKind.DIRECTION: frozenset({"script-direction/v1"}),
-    ScriptTaskKind.PATTERN_RETRIEVAL: frozenset({"evidence-record/v1"}),
-    ScriptTaskKind.DECODE_RETRIEVAL: frozenset({"evidence-record/v1"}),
-    ScriptTaskKind.EXTERNAL_RETRIEVAL: frozenset({"evidence-record/v1"}),
-    ScriptTaskKind.KNOWLEDGE_RETRIEVAL: frozenset({"evidence-record/v1"}),
-    ScriptTaskKind.STRUCTURE: frozenset({"structure-artifact/v1"}),
-    ScriptTaskKind.PARAGRAPH: frozenset({"paragraph-artifact/v1", "paragraph-patch/v1"}),
-    ScriptTaskKind.ELEMENT_SET: frozenset({"element-set-artifact/v1", "element-set-patch/v1"}),
-    ScriptTaskKind.COMPARE: frozenset({"comparison-artifact/v1"}),
-    ScriptTaskKind.COMPOSE: frozenset({"structured-script/v1"}),
-    ScriptTaskKind.CANDIDATE_PORTFOLIO: frozenset({"candidate-portfolio/v1"}),
-    ScriptTaskKind.ROOT_DELIVERY: frozenset({"root-delivery-manifest/v1"}),
-}
-
 _ADOPTION_FIELDS_KINDS = frozenset({ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO})
 _COMPARISON_FIELDS_KINDS = frozenset({ScriptTaskKind.COMPARE, ScriptTaskKind.COMPOSE})
 
@@ -282,108 +238,86 @@ class ScriptTaskBudget:
 
 
 @dataclass(frozen=True, slots=True)
-class PlannerTaskInput:
-    """Planner-owned semantics; the Host derives every immutable reference."""
+class PlannerTaskIntentV2:
+    """Business-only Planner request; the Host owns all execution mechanics."""
 
     task_kind: ScriptTaskKind
-    scope_selector: ScopeSelector
-    intent_class: ScriptIntentClass
     objective: str
-    goal_ids: tuple[str, ...]
-    criteria: tuple[PlannerCriterionInput, ...] = ()
-    input_decision_ids: tuple[str, ...] = ()
-    base_decision_id: str | None = None
-    comparison_decision_ids: tuple[str, ...] = ()
-    supersedes_decision_ids: tuple[str, ...] = ()
-    gap_key: str | None = None
+    decision_ids: tuple[str, ...] = ()
+    change_mode: PlannerChangeMode = PlannerChangeMode.NEW
+    target_goal_ids: tuple[str, ...] = ()
+    quality_requirements: tuple[str, ...] = ()
+    evidence_request: str | None = None
+    within_task_id: str | None = None
 
     def __post_init__(self) -> None:
-        if self.task_kind is ScriptTaskKind.ROOT_DELIVERY:
-            raise TaskContractError("TASK_CONTRACT_INVALID", "Root Delivery is frozen by the Host")
         _require_bounded_text(self.objective, "objective", MAX_OBJECTIVE_CHARS)
-        if len(self.criteria) > MAX_CRITERIA:
-            raise TaskContractError(
-                "TASK_CONTRACT_INVALID",
-                f"criteria may contain at most {MAX_CRITERIA} business items",
-            )
-        _require_unique((item.client_key for item in self.criteria), "criterion client keys")
-        if len(self.goal_ids) > MAX_DIRECTION_GOALS:
-            raise TaskContractError(
-                "GOAL_SCOPE_INVALID",
-                f"goal_ids may contain at most {MAX_DIRECTION_GOALS} values",
-            )
-        if any(not item.strip() for item in self.goal_ids):
-            raise TaskContractError("GOAL_SCOPE_INVALID", "goal_ids must not contain blanks")
-        for label, values in (
-            ("goal_ids", self.goal_ids),
-            ("input_decision_ids", self.input_decision_ids),
-            ("comparison_decision_ids", self.comparison_decision_ids),
-            ("supersedes_decision_ids", self.supersedes_decision_ids),
+        for label, values, maximum in (
+            ("decision_ids", self.decision_ids, MAX_REFERENCES),
+            ("target_goal_ids", self.target_goal_ids, MAX_DIRECTION_GOALS),
+            ("quality_requirements", self.quality_requirements, MAX_CRITERIA),
         ):
             _require_unique(values, label)
-        for value in (
-            *self.input_decision_ids,
-            *self.comparison_decision_ids,
-            *self.supersedes_decision_ids,
-        ):
+            if len(values) > maximum:
+                raise TaskContractError(
+                    "TASK_CONTRACT_INVALID", f"{label} may contain at most {maximum} values"
+                )
+        for value in self.decision_ids:
             _require_identifier(value, "decision_id")
-        if self.base_decision_id is not None:
-            _require_identifier(self.base_decision_id, "base_decision_id")
-        if self.gap_key is not None:
-            _require_identifier(self.gap_key, "gap_key")
-        if self.task_kind is ScriptTaskKind.COMPARE and len(self.comparison_decision_ids) < 2:
+        for value in self.target_goal_ids:
+            _require_identifier(value, "target_goal_id")
+        for value in self.quality_requirements:
+            _require_bounded_text(value, "quality requirement", 500)
+        if self.evidence_request is not None:
+            _require_bounded_text(self.evidence_request, "evidence_request", 1_000)
+        if self.within_task_id is not None:
+            _require_identifier(self.within_task_id, "within_task_id")
+        if self.change_mode is PlannerChangeMode.REPLACE and not self.decision_ids:
             raise TaskContractError(
-                "TASK_CONTRACT_INVALID", "Compare requires at least two Decision IDs"
-            )
-        if self.task_kind is not ScriptTaskKind.COMPARE and self.comparison_decision_ids:
-            raise TaskContractError(
-                "TASK_CONTRACT_INVALID", "comparison_decision_ids require a Compare Task"
+                "TASK_CONTRACT_INVALID", "replace requires an accepted Decision"
             )
 
     @classmethod
-    def from_payload(cls, value: Mapping[str, Any]) -> PlannerTaskInput:
+    def from_payload(cls, value: Mapping[str, Any]) -> PlannerTaskIntentV2:
         allowed = {
             "task_kind",
-            "scope_selector",
-            "intent_class",
             "objective",
-            "goal_ids",
-            "criteria",
-            "input_decision_ids",
-            "base_decision_id",
-            "comparison_decision_ids",
-            "supersedes_decision_ids",
-            "gap_key",
+            "decision_ids",
+            "change_mode",
+            "target_goal_ids",
+            "quality_requirements",
+            "evidence_request",
+            "within_task_id",
         }
         unknown = sorted(set(value) - allowed)
         if unknown:
             raise TaskContractError(
                 "TASK_CONTRACT_INVALID",
-                f"PlannerTaskInput contains Host-owned fields: {unknown}",
+                f"PlannerTaskIntentV2 contains Host-owned fields: {unknown}",
             )
         return cls(
             task_kind=_enum_value(ScriptTaskKind, value.get("task_kind"), "task_kind"),
-            scope_selector=ScopeSelector.from_payload(value.get("scope_selector")),
-            intent_class=_enum_value(ScriptIntentClass, value.get("intent_class"), "intent_class"),
             objective=str(value.get("objective", "")),
-            goal_ids=_strings(value.get("goal_ids"), "goal_ids"),
-            criteria=tuple(
-                PlannerCriterionInput.from_payload(item)
-                for item in _objects(value.get("criteria"), "criteria")
+            decision_ids=_strings(value.get("decision_ids"), "decision_ids"),
+            change_mode=_enum_value(
+                PlannerChangeMode,
+                value.get("change_mode", PlannerChangeMode.NEW),
+                "change_mode",
             ),
-            input_decision_ids=_strings(value.get("input_decision_ids"), "input_decision_ids"),
-            base_decision_id=(
-                str(value["base_decision_id"])
-                if value.get("base_decision_id") is not None
-                else None
+            target_goal_ids=_strings(value.get("target_goal_ids"), "target_goal_ids"),
+            quality_requirements=_strings(
+                value.get("quality_requirements"), "quality_requirements"
             ),
-            comparison_decision_ids=_strings(
-                value.get("comparison_decision_ids"), "comparison_decision_ids"
+            evidence_request=(
+                str(value["evidence_request"])
+                if value.get("evidence_request") is not None
+                else None
             ),
-            supersedes_decision_ids=_strings(
-                value.get("supersedes_decision_ids"), "supersedes_decision_ids"
+            within_task_id=(
+                str(value["within_task_id"])
+                if value.get("within_task_id") is not None
+                else None
             ),
-            gap_key=str(value["gap_key"]) if value.get("gap_key") is not None else None,
         )
 
 
@@ -451,11 +385,7 @@ class ScriptTaskContractV1:
     def __post_init__(self) -> None:
         _require_control_uri(self.scope_ref, "scope_ref")
         _require_bounded_text(self.objective, "objective", MAX_OBJECTIVE_CHARS)
-        if self.output_schema not in _OUTPUT_SCHEMAS[self.task_kind]:
-            raise TaskContractError(
-                "TASK_CONTRACT_INVALID",
-                "output_schema does not match the declared task kind",
-            )
+        _require_bounded_text(self.output_schema, "output_schema", 128)
         if not self.criteria or len(self.criteria) > MAX_CRITERIA:
             raise TaskContractError(
                 "TASK_CONTRACT_INVALID", f"criteria must contain between 1 and {MAX_CRITERIA} items"
@@ -682,6 +612,188 @@ class ScriptTaskContractV1:
         )
 
 
+class ResourceClaimMode(StrEnum):
+    SHARED = "shared"
+    EXCLUSIVE = "exclusive"
+
+
+@dataclass(frozen=True, slots=True)
+class ExecutionProfile:
+    worker_preset: str
+    validator_preset: str
+    allowed_tools: tuple[str, ...]
+    validator_allowed_tools: tuple[str, ...]
+    completion_mode: str
+    capability_digest: str
+
+    def __post_init__(self) -> None:
+        for value, label in (
+            (self.worker_preset, "worker_preset"),
+            (self.validator_preset, "validator_preset"),
+            (self.completion_mode, "completion_mode"),
+        ):
+            _require_identifier(value, label)
+        _require_unique(self.allowed_tools, "allowed_tools")
+        _require_unique(self.validator_allowed_tools, "validator_allowed_tools")
+        for tool in (*self.allowed_tools, *self.validator_allowed_tools):
+            _require_identifier(tool, "allowed_tool")
+        Sha256Digest.from_wire(self.capability_digest)
+
+    def to_payload(self) -> dict[str, Any]:
+        return {
+            "worker_preset": self.worker_preset,
+            "validator_preset": self.validator_preset,
+            "allowed_tools": list(self.allowed_tools),
+            "validator_allowed_tools": list(self.validator_allowed_tools),
+            "completion_mode": self.completion_mode,
+            "capability_digest": self.capability_digest,
+        }
+
+    @classmethod
+    def from_payload(cls, value: Mapping[str, Any]) -> ExecutionProfile:
+        return cls(
+            worker_preset=str(value.get("worker_preset", "")),
+            validator_preset=str(value.get("validator_preset", "")),
+            allowed_tools=_strings(value.get("allowed_tools"), "allowed_tools"),
+            validator_allowed_tools=_strings(
+                value.get("validator_allowed_tools"), "validator_allowed_tools"
+            ),
+            completion_mode=str(value.get("completion_mode", "")),
+            capability_digest=str(value.get("capability_digest", "")),
+        )
+
+
+@dataclass(frozen=True, slots=True)
+class ResourceClaim:
+    resource_ref: str
+    mode: ResourceClaimMode = ResourceClaimMode.EXCLUSIVE
+
+    def __post_init__(self) -> None:
+        _require_control_uri(self.resource_ref, "resource_ref")
+
+    def to_payload(self) -> dict[str, str]:
+        return {"resource_ref": self.resource_ref, "mode": self.mode.value}
+
+    @classmethod
+    def from_payload(cls, value: Mapping[str, Any]) -> ResourceClaim:
+        return cls(
+            resource_ref=str(value.get("resource_ref", "")),
+            mode=_enum_value(ResourceClaimMode, value.get("mode"), "resource claim mode"),
+        )
+
+
+@dataclass(frozen=True, slots=True)
+class WorkspaceSeedDescriptor:
+    source_kind: ScriptTaskKind
+    source_decision_id: str
+    semantic_target_ref: str
+
+    def __post_init__(self) -> None:
+        _require_identifier(self.source_decision_id, "workspace seed decision_id")
+        _require_control_uri(self.semantic_target_ref, "semantic_target_ref")
+
+    def to_payload(self) -> dict[str, str]:
+        return {
+            "source_kind": self.source_kind.value,
+            "source_decision_id": self.source_decision_id,
+            "semantic_target_ref": self.semantic_target_ref,
+        }
+
+    @classmethod
+    def from_payload(cls, value: Mapping[str, Any]) -> WorkspaceSeedDescriptor:
+        return cls(
+            source_kind=_enum_value(
+                ScriptTaskKind, value.get("source_kind"), "workspace seed source_kind"
+            ),
+            source_decision_id=str(value.get("source_decision_id", "")),
+            semantic_target_ref=str(value.get("semantic_target_ref", "")),
+        )
+
+
+@dataclass(frozen=True, slots=True)
+class ScriptTaskContractV2(ScriptTaskContractV1):
+    branch_ref: str = "script-build://branches/legacy"
+    execution_profile: ExecutionProfile = field(
+        default_factory=lambda: ExecutionProfile(
+            "legacy_worker",
+            "legacy_validator",
+            (),
+            (),
+            "worker_submits",
+            "sha256:" + "0" * 64,
+        )
+    )
+    resource_claims: tuple[ResourceClaim, ...] = ()
+    workspace_seed: WorkspaceSeedDescriptor | None = None
+    evidence_requirements: EvidenceRequirementManifestV1 = field(
+        default_factory=lambda: EvidenceRequirementManifestV1(())
+    )
+    catalog_digest: str = "sha256:" + "0" * 64
+
+    def __post_init__(self) -> None:
+        ScriptTaskContractV1.__post_init__(self)
+        _require_control_uri(self.branch_ref, "branch_ref")
+        _require_unique((item.resource_ref for item in self.resource_claims), "resource claims")
+        Sha256Digest.from_wire(self.catalog_digest)
+
+    def to_payload(self) -> dict[str, Any]:
+        payload = ScriptTaskContractV1.to_payload(self)
+        payload.update(
+            {
+                "schema_version": "script-task-contract/v2",
+                "branch_ref": self.branch_ref,
+                "execution_profile": self.execution_profile.to_payload(),
+                "resource_claims": [item.to_payload() for item in self.resource_claims],
+                "workspace_seed": (
+                    self.workspace_seed.to_payload() if self.workspace_seed else None
+                ),
+                "evidence_requirements": self.evidence_requirements.to_payload(),
+                "catalog_digest": self.catalog_digest,
+            }
+        )
+        return payload
+
+    @classmethod
+    def from_payload(cls, value: Mapping[str, Any]) -> ScriptTaskContractV2:
+        if value.get("schema_version") != "script-task-contract/v2":
+            raise TaskContractError(
+                "TASK_CONTRACT_INVALID", "unsupported task contract schema version"
+            )
+        legacy_payload = dict(value)
+        legacy_payload["schema_version"] = "script-task-contract/v1"
+        legacy = ScriptTaskContractV1.from_payload(legacy_payload)
+        execution = value.get("execution_profile")
+        claims = value.get("resource_claims")
+        seed = value.get("workspace_seed")
+        evidence = value.get("evidence_requirements")
+        if not isinstance(execution, Mapping):
+            raise TaskContractError("TASK_CONTRACT_INVALID", "execution_profile is required")
+        if not isinstance(claims, list) or any(not isinstance(item, Mapping) for item in claims):
+            raise TaskContractError("TASK_CONTRACT_INVALID", "resource_claims are invalid")
+        if seed is not None and not isinstance(seed, Mapping):
+            raise TaskContractError("TASK_CONTRACT_INVALID", "workspace_seed is invalid")
+        if not isinstance(evidence, Mapping):
+            raise TaskContractError("TASK_CONTRACT_INVALID", "evidence manifest is required")
+        return cls(
+            **_v1_contract_values(legacy),
+            branch_ref=str(value.get("branch_ref", "")),
+            execution_profile=ExecutionProfile.from_payload(execution),
+            resource_claims=tuple(ResourceClaim.from_payload(item) for item in claims),
+            workspace_seed=(
+                WorkspaceSeedDescriptor.from_payload(seed) if seed is not None else None
+            ),
+            evidence_requirements=EvidenceRequirementManifestV1.from_payload(evidence),
+            catalog_digest=str(value.get("catalog_digest", "")),
+        )
+
+
+def _v1_contract_values(value: ScriptTaskContractV1) -> dict[str, Any]:
+    return {
+        name: getattr(value, name)
+        for name in ScriptTaskContractV1.__dataclass_fields__
+    }
+
+
 def stable_semantic_id(task_id: str, kind: str, client_key: str) -> str:
     """Generate an order-independent ID for model-owned semantic content."""
 
@@ -695,7 +807,7 @@ class FrozenTaskContract:
     uri: str
     digest: str
     canonical_size: int
-    contract: ScriptTaskContractV1
+    contract: ScriptTaskContractV1 | ScriptTaskContractV2
 
     def __post_init__(self) -> None:
         Sha256Digest.from_wire(self.digest)
@@ -842,14 +954,6 @@ def _enum_value(enum_type: type[Any], value: Any, label: str) -> Any:
         raise TaskContractError("TASK_CONTRACT_INVALID", f"unsupported {label}") from exc
 
 
-def output_schema_for(kind: ScriptTaskKind, *, patched: bool = False) -> str:
-    if kind is ScriptTaskKind.PARAGRAPH:
-        return "paragraph-patch/v1" if patched else "paragraph-artifact/v1"
-    if kind is ScriptTaskKind.ELEMENT_SET:
-        return "element-set-patch/v1" if patched else "element-set-artifact/v1"
-    return next(iter(_OUTPUT_SCHEMAS[kind]))
-
-
 __all__ = [
     "MAX_ATTEMPTS_PER_TASK",
     "MAX_CANDIDATES_PER_SCOPE",
@@ -864,19 +968,22 @@ __all__ = [
     "AcceptedDecisionRef",
     "AcceptedInput",
     "AcceptedInputBundleV1",
+    "ExecutionProfile",
     "FrozenTaskContract",
     "PhaseTwoLimits",
+    "PlannerChangeMode",
     "PlannerCriterionInput",
-    "PlannerTaskInput",
-    "ScopeAnchor",
-    "ScopeSelector",
+    "PlannerTaskIntentV2",
+    "ResourceClaim",
+    "ResourceClaimMode",
     "ScriptCriterion",
     "ScriptIntentClass",
     "ScriptTaskBudget",
     "ScriptTaskContractV1",
+    "ScriptTaskContractV2",
     "ScriptTaskKind",
     "TaskContractError",
-    "output_schema_for",
+    "WorkspaceSeedDescriptor",
     "stable_semantic_id",
     "task_kind_from_context_refs",
 ]

+ 15 - 75
script_build_host/src/script_build_host/domain/task_policy.py

@@ -3,10 +3,13 @@
 from __future__ import annotations
 
 from collections.abc import Sequence
-from hashlib import sha256
 from typing import Any
 
-from .task_capabilities import render_criterion, validate_business_criteria
+from .task_capabilities import (
+    TaskCapabilityCatalog,
+    render_criterion,
+    validate_business_criteria,
+)
 from .task_contracts import (
     PlannerCriterionInput,
     ScriptCriterion,
@@ -14,73 +17,11 @@ from .task_contracts import (
     stable_semantic_id,
 )
 
-_FIXED: dict[ScriptTaskKind, tuple[ScriptCriterion, ...]] = {
-    ScriptTaskKind.DIRECTION: (
-        ScriptCriterion(
-            "direction-coherent", "Direction goals and hard constraints are coherent", True
-        ),
-    ),
-    ScriptTaskKind.PATTERN_RETRIEVAL: (
-        ScriptCriterion(
-            "evidence-usable", "Retrieval returns usable and attributable evidence", True
-        ),
-    ),
-    ScriptTaskKind.DECODE_RETRIEVAL: (
-        ScriptCriterion(
-            "evidence-usable", "Retrieval returns usable and attributable evidence", True
-        ),
-    ),
-    ScriptTaskKind.EXTERNAL_RETRIEVAL: (
-        ScriptCriterion(
-            "evidence-usable", "Retrieval returns usable and attributable evidence", True
-        ),
-    ),
-    ScriptTaskKind.KNOWLEDGE_RETRIEVAL: (
-        ScriptCriterion(
-            "evidence-usable", "Retrieval returns usable and attributable evidence", True
-        ),
-    ),
-    ScriptTaskKind.STRUCTURE: (
-        ScriptCriterion("structure-complete", "Structure covers its declared content scope", True),
-    ),
-    ScriptTaskKind.PARAGRAPH: (
-        ScriptCriterion(
-            "paragraph-content-complete", "Paragraph contains complete publishable prose", True
-        ),
-    ),
-    ScriptTaskKind.ELEMENT_SET: (
-        ScriptCriterion(
-            "elements-linked", "Every Element is valid and linked to a Paragraph", True
-        ),
-    ),
-    ScriptTaskKind.COMPARE: (
-        ScriptCriterion(
-            "comparison-grounded", "Recommendation is grounded in the frozen candidates", True
-        ),
-    ),
-    ScriptTaskKind.COMPOSE: (
-        ScriptCriterion(
-            "script-content-complete",
-            "Composed script satisfies the complete content contract",
-            True,
-        ),
-        ScriptCriterion(
-            "direction-goals-satisfied", "All Direction goals have real creative coverage", True
-        ),
-    ),
-    ScriptTaskKind.CANDIDATE_PORTFOLIO: (
-        ScriptCriterion(
-            "portfolio-closed", "Portfolio closes and adopts exactly one valid script", True
-        ),
-        ScriptCriterion(
-            "direction-goals-satisfied", "All Direction goals have real creative coverage", True
-        ),
-    ),
-    ScriptTaskKind.ROOT_DELIVERY: (),
-}
-
 
 class TaskPolicyCatalog:
+    def __init__(self, capabilities: TaskCapabilityCatalog | None = None) -> None:
+        self._capabilities = capabilities or TaskCapabilityCatalog()
+
     def criteria(
         self,
         *,
@@ -89,8 +30,12 @@ class TaskPolicyCatalog:
         business: tuple[PlannerCriterionInput, ...],
         direction_constraints: Sequence[Any] = (),
     ) -> tuple[ScriptCriterion, ...]:
-        validate_business_criteria(task_kind, business)
-        fixed = _FIXED[task_kind]
+        validate_business_criteria(
+            task_kind,
+            business,
+            capabilities=self._capabilities,
+        )
+        fixed = self._capabilities.get(task_kind).fixed_criteria
         fixed_ids = {item.criterion_id for item in fixed}
         constraints = tuple(
             ScriptCriterion(
@@ -115,12 +60,7 @@ class TaskPolicyCatalog:
 
     @property
     def manifest_digest(self) -> str:
-        encoded = "\n".join(
-            f"{kind.value}:{item.criterion_id}:{item.description}:{int(item.hard)}"
-            for kind in ScriptTaskKind
-            for item in _FIXED[kind]
-        )
-        return "sha256:" + sha256(encoded.encode()).hexdigest()
+        return self._capabilities.digest
 
 
 __all__ = ["TaskPolicyCatalog"]

+ 18 - 1
script_build_host/src/script_build_host/infrastructure/task_contract_store.py

@@ -13,9 +13,14 @@ from tempfile import NamedTemporaryFile
 from typing import Any
 
 from script_build_host.domain.digests import Sha256Digest
+from script_build_host.domain.task_capabilities import (
+    LegacyTaskCapabilityCatalog,
+    TaskCapabilityCatalog,
+)
 from script_build_host.domain.task_contracts import (
     FrozenTaskContract,
     ScriptTaskContractV1,
+    ScriptTaskContractV2,
     TaskContractError,
 )
 from script_build_host.infrastructure.canonical_json import canonical_json_bytes
@@ -137,7 +142,19 @@ class FileScriptTaskContractStore:
             raise TaskContractError(
                 "TASK_CONTRACT_DIGEST_MISMATCH", "stored task contract is not canonical JSON"
             )
-        contract = ScriptTaskContractV1.from_payload(payload)
+        if payload.get("schema_version") == "script-task-contract/v2":
+            contract: ScriptTaskContractV1 = ScriptTaskContractV2.from_payload(
+                payload
+            )
+            capability = TaskCapabilityCatalog().get(contract.task_kind)
+        else:
+            contract = ScriptTaskContractV1.from_payload(payload)
+            capability = LegacyTaskCapabilityCatalog().get(contract.task_kind)
+        if contract.output_schema not in capability.output_schemas:
+            raise TaskContractError(
+                "TASK_CONTRACT_INVALID",
+                "output_schema does not match the frozen task capability",
+            )
         return FrozenTaskContract(
             uri=f"{_URI_PREFIX}{digest}",
             digest=Sha256Digest(digest).wire,

+ 1 - 5
script_build_host/src/script_build_host/tools/contracts.py

@@ -22,8 +22,7 @@ class ScriptPlannerToolPort(Protocol):
     async def plan_script_tasks(
         self,
         *,
-        contract_payloads: Sequence[Mapping[str, Any]],
-        parent_task_id: str | None,
+        intents: Sequence[Mapping[str, Any]],
         context: Mapping[str, Any],
     ) -> Mapping[str, Any]: ...
 
@@ -34,9 +33,6 @@ class ScriptPlannerToolPort(Protocol):
         action: str,
         reason: str,
         validation_id: str | None,
-        replacement_contract: Mapping[str, Any] | None,
-        child_contracts: Sequence[Mapping[str, Any]],
-        selected_decision_ids: Sequence[str],
         context: Mapping[str, Any],
     ) -> Mapping[str, Any]: ...
 

+ 638 - 0
script_build_host/tests/test_architecture_contract_compiler.py

@@ -0,0 +1,638 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from agent.orchestration import ArtifactRef, ResourceClaim, TaskStatus
+
+from script_build_host.application.direction_evidence_gate import DirectionEvidenceGate
+from script_build_host.application.phase_two_planning import (
+    _claim_serialized_waves,
+)
+from script_build_host.domain.errors import RequiredEvidenceUnsatisfied
+from script_build_host.domain.evidence_requirements import (
+    DirectionEvidenceRequirementFactory,
+    EvidenceClosureEvaluator,
+    EvidenceGate,
+    EvidenceItem,
+    EvidenceProvider,
+)
+from script_build_host.domain.task_contract_compiler import (
+    PlanningContainer,
+    PlanningDecision,
+    PlanningSnapshot,
+    PlanningTarget,
+    TaskContractCompiler,
+    TaskContractMutation,
+)
+from script_build_host.domain.task_contracts import (
+    AcceptedDecisionRef,
+    PlannerTaskIntentV2,
+    ScriptCriterion,
+    ScriptTaskBudget,
+    ScriptTaskKind,
+    TaskContractError,
+)
+from script_build_host.infrastructure.task_contract_store import (
+    FileScriptTaskContractStore,
+)
+
+
+def _decision(
+    identity: str, kind: ScriptTaskKind, branch: str
+) -> PlanningDecision:
+    return PlanningDecision(
+        AcceptedDecisionRef(
+            identity,
+            ArtifactRef(
+                uri="script-build://artifact-versions/1",
+                kind="artifact",
+                version="1",
+                digest="sha256:" + "1" * 64,
+            ),
+            f"script-build://scopes/{branch}/{kind.value}",
+            kind,
+        ),
+        f"producer-{identity}",
+        f"script-build://branches/{branch}",
+        ("goal-a",),
+    )
+
+
+def test_same_workspace_lane_is_serialized_while_distinct_lanes_share_a_wave() -> None:
+    lane_a = (ResourceClaim("script-build://resources/workspace/a"),)
+    lane_b = (ResourceClaim("script-build://resources/workspace/b"),)
+    waves = _claim_serialized_waves(
+        (
+            ("paragraph-a", "worker", lane_a),
+            ("element-a", "worker", lane_a),
+            ("paragraph-b", "worker", lane_b),
+        )
+    )
+    assert tuple(item[0] for item in waves[0]) == (
+        "paragraph-a",
+        "paragraph-b",
+    )
+    assert tuple(item[0] for item in waves[1]) == ("element-a",)
+
+
+def test_host_owned_root_delivery_uses_the_v2_catalog_contract() -> None:
+    direction = _decision(
+        "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
+    ).ref
+    portfolio = _decision(
+        "portfolio-1", ScriptTaskKind.CANDIDATE_PORTFOLIO, "portfolio"
+    ).ref
+    contract = TaskContractCompiler().compile_host_contract(
+        task_id="root-task",
+        parent_task_id="root-task",
+        task_kind=ScriptTaskKind.ROOT_DELIVERY,
+        objective="publish",
+        scope_ref=direction.scope_ref,
+        branch_ref="script-build://branches/root-delivery",
+        input_decision_refs=(direction, portfolio),
+        criteria=(ScriptCriterion("published", "publish", True),),
+        budget=ScriptTaskBudget(),
+        goal_ids=("goal-a",),
+    )
+    assert contract.to_payload()["schema_version"] == "script-task-contract/v2"
+    assert contract.execution_profile.worker_preset == "script_root_worker"
+    assert contract.catalog_digest == TaskContractCompiler().capabilities.digest
+
+
+@pytest.mark.asyncio
+async def test_file_contract_reader_preserves_v2_frozen_execution_profile(
+    tmp_path,
+) -> None:
+    direction = _decision(
+        "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
+    ).ref
+    portfolio = _decision(
+        "portfolio-1", ScriptTaskKind.CANDIDATE_PORTFOLIO, "portfolio"
+    ).ref
+    contract = TaskContractCompiler().compile_host_contract(
+        task_id="root-task",
+        parent_task_id="root-task",
+        task_kind=ScriptTaskKind.ROOT_DELIVERY,
+        objective="publish",
+        scope_ref=direction.scope_ref,
+        branch_ref="script-build://branches/root-delivery",
+        input_decision_refs=(direction, portfolio),
+        criteria=(ScriptCriterion("published", "publish", True),),
+        budget=ScriptTaskBudget(),
+        goal_ids=("goal-a",),
+    )
+    store = FileScriptTaskContractStore(tmp_path)
+    frozen = await store.freeze("root", contract)
+
+    replay = await store.read("root", frozen.uri)
+
+    assert replay.contract == contract
+
+
+def test_intent_order_does_not_change_compiled_contracts() -> None:
+    snapshot = PlanningSnapshot(
+        root_trace_id="root",
+        phase=2,
+        containers=(
+            PlanningContainer(
+                "root-task",
+                None,
+                None,
+                None,
+            ),
+        ),
+        decisions=(),
+        active_direction_ref=_decision(
+            "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
+        ).ref,
+        direction_goal_ids=("goal-a",),
+    )
+    intents = (
+        PlannerTaskIntentV2(
+            ScriptTaskKind.STRUCTURE, "approach a", target_goal_ids=("goal-a",)
+        ),
+        PlannerTaskIntentV2(
+            ScriptTaskKind.STRUCTURE, "approach b", target_goal_ids=("goal-a",)
+        ),
+    )
+    compiler = TaskContractCompiler()
+    first = compiler.compile_batch(
+        intents, snapshot, command_id="command", budget=ScriptTaskBudget()
+    )
+    second = compiler.compile_batch(
+        tuple(reversed(intents)),
+        snapshot,
+        command_id="command",
+        budget=ScriptTaskBudget(),
+    )
+    assert [item.contract.to_payload() for item in first] == [
+        item.contract.to_payload() for item in second
+    ]
+    assert [item.contract.task_kind for item in first].count(
+        ScriptTaskKind.CANDIDATE_PORTFOLIO
+    ) == 1
+    assert [item.contract.task_kind for item in first].count(
+        ScriptTaskKind.COMPOSE
+    ) == 2
+
+
+def test_phase_two_scaffolds_then_closes_without_planner_contract_fields() -> None:
+    compiler = TaskContractCompiler()
+    direction = _decision("direction-1", ScriptTaskKind.DIRECTION, "phase-one").ref
+    initial = PlanningSnapshot(
+        "root",
+        2,
+        (PlanningContainer("root-task", None, None, None),),
+        (),
+        active_direction_ref=direction,
+        direction_goal_ids=("goal-a",),
+    )
+    opened = compiler.compile_batch(
+        (
+            PlannerTaskIntentV2(
+                ScriptTaskKind.STRUCTURE,
+                "open one creative approach",
+                target_goal_ids=("goal-a",),
+            ),
+        ),
+        initial,
+        command_id="open",
+        budget=ScriptTaskBudget(),
+    )
+    portfolio = next(
+        item
+        for item in opened
+        if item.contract.task_kind is ScriptTaskKind.CANDIDATE_PORTFOLIO
+    )
+    compose = next(
+        item for item in opened if item.contract.task_kind is ScriptTaskKind.COMPOSE
+    )
+    structure = next(
+        item for item in opened if item.contract.task_kind is ScriptTaskKind.STRUCTURE
+    )
+    assert not portfolio.contract.execution_ready
+    assert not compose.contract.execution_ready
+    assert structure.parent_task_id == compose.task_id
+
+    structure_decision = PlanningDecision(
+        AcceptedDecisionRef(
+            "structure-1",
+            ArtifactRef(
+                uri="script-build://artifact-versions/10",
+                kind="artifact",
+                version="10",
+                digest="sha256:" + "a" * 64,
+            ),
+            structure.contract.scope_ref,
+            ScriptTaskKind.STRUCTURE,
+        ),
+        structure.task_id,
+        structure.contract.branch_ref,
+        structure.contract.goal_ids,
+    )
+    containers = (
+        PlanningContainer("root-task", None, None, None),
+        PlanningContainer(
+            portfolio.task_id,
+            portfolio.contract.task_kind,
+            portfolio.contract.scope_ref,
+            portfolio.contract.branch_ref,
+            portfolio.contract.goal_ids,
+            portfolio.parent_task_id,
+            True,
+        ),
+        PlanningContainer(
+            compose.task_id,
+            compose.contract.task_kind,
+            compose.contract.scope_ref,
+            compose.contract.branch_ref,
+            compose.contract.goal_ids,
+            compose.parent_task_id,
+            True,
+        ),
+    )
+    paragraph = compiler.compile_batch(
+        (
+            PlannerTaskIntentV2(
+                ScriptTaskKind.PARAGRAPH,
+                "write paragraph",
+                decision_ids=("structure-1",),
+                target_goal_ids=("goal-a",),
+            ),
+        ),
+        PlanningSnapshot(
+            "root",
+            2,
+            containers,
+            (structure_decision,),
+            (
+                PlanningTarget(
+                    "script-build://targets/paragraph-1",
+                    "structure-1",
+                    ScriptTaskKind.STRUCTURE,
+                    structure.contract.branch_ref,
+                    ("goal-a",),
+                ),
+            ),
+            direction,
+            ("goal-a",),
+        ),
+        command_id="paragraph",
+        budget=ScriptTaskBudget(),
+    )[0]
+    paragraph_decision = PlanningDecision(
+        AcceptedDecisionRef(
+            "paragraph-1",
+            ArtifactRef(
+                uri="script-build://artifact-versions/11",
+                kind="artifact",
+                version="11",
+                digest="sha256:" + "b" * 64,
+            ),
+            paragraph.contract.scope_ref,
+            ScriptTaskKind.PARAGRAPH,
+        ),
+        paragraph.task_id,
+        paragraph.contract.branch_ref,
+        paragraph.contract.goal_ids,
+    )
+    element = compiler.compile_batch(
+        (
+            PlannerTaskIntentV2(
+                ScriptTaskKind.ELEMENT_SET,
+                "add elements",
+                decision_ids=("paragraph-1",),
+                target_goal_ids=("goal-a",),
+            ),
+        ),
+        PlanningSnapshot(
+            "root",
+            2,
+            containers,
+            (paragraph_decision,),
+            (
+                PlanningTarget(
+                    "script-build://targets/paragraph-1",
+                    "paragraph-1",
+                    ScriptTaskKind.PARAGRAPH,
+                    paragraph.contract.branch_ref,
+                    ("goal-a",),
+                ),
+            ),
+            direction,
+            ("goal-a",),
+        ),
+        command_id="element",
+        budget=ScriptTaskBudget(),
+    )[0]
+    element_decision = PlanningDecision(
+        AcceptedDecisionRef(
+            "element-1",
+            ArtifactRef(
+                uri="script-build://artifact-versions/12",
+                kind="artifact",
+                version="12",
+                digest="sha256:" + "c" * 64,
+            ),
+            element.contract.scope_ref,
+            ScriptTaskKind.ELEMENT_SET,
+        ),
+        element.task_id,
+        element.contract.branch_ref,
+        element.contract.goal_ids,
+    )
+    closed_compose = compiler.compile_batch(
+        (
+            PlannerTaskIntentV2(
+                ScriptTaskKind.COMPOSE,
+                "compose accepted branch",
+                decision_ids=("structure-1", "paragraph-1", "element-1"),
+            ),
+        ),
+        PlanningSnapshot(
+            "root",
+            2,
+            containers,
+            (structure_decision, paragraph_decision, element_decision),
+            active_direction_ref=direction,
+            direction_goal_ids=("goal-a",),
+        ),
+        command_id="compose",
+        budget=ScriptTaskBudget(),
+    )[0]
+    assert closed_compose.task_id == compose.task_id
+    assert closed_compose.mutation is TaskContractMutation.REVISE
+    assert closed_compose.contract.execution_ready
+
+    compose_decision = PlanningDecision(
+        AcceptedDecisionRef(
+            "compose-1",
+            ArtifactRef(
+                uri="script-build://artifact-versions/13",
+                kind="artifact",
+                version="13",
+                digest="sha256:" + "d" * 64,
+            ),
+            closed_compose.contract.scope_ref,
+            ScriptTaskKind.COMPOSE,
+        ),
+        compose.task_id,
+        compose.contract.branch_ref,
+        closed_compose.contract.goal_ids,
+    )
+    closed_portfolio = compiler.compile_batch(
+        (
+            PlannerTaskIntentV2(
+                ScriptTaskKind.CANDIDATE_PORTFOLIO,
+                "adopt accepted script",
+                decision_ids=("compose-1",),
+            ),
+        ),
+        PlanningSnapshot(
+            "root",
+            2,
+            containers,
+            (compose_decision,),
+            active_direction_ref=direction,
+            direction_goal_ids=("goal-a",),
+        ),
+        command_id="portfolio",
+        budget=ScriptTaskBudget(),
+    )[0]
+    assert closed_portfolio.task_id == portfolio.task_id
+    assert closed_portfolio.mutation is TaskContractMutation.REVISE
+    assert closed_portfolio.contract.execution_ready
+
+
+def test_compose_scaffold_cannot_close_before_child_frontier_is_terminal() -> None:
+    decisions = tuple(
+        _decision(f"{kind.value}-1", kind, "branch-a")
+        for kind in (
+            ScriptTaskKind.STRUCTURE,
+            ScriptTaskKind.PARAGRAPH,
+            ScriptTaskKind.ELEMENT_SET,
+        )
+    )
+    snapshot = PlanningSnapshot(
+        "root",
+        2,
+        (
+            PlanningContainer("root-task", None, None, None),
+            PlanningContainer(
+                "compose-parent",
+                ScriptTaskKind.COMPOSE,
+                "script-build://scopes/compose",
+                "script-build://branches/branch-a",
+                ("goal-a",),
+                "portfolio-parent",
+                False,
+            ),
+        ),
+        decisions,
+        active_direction_ref=_decision(
+            "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
+        ).ref,
+        direction_goal_ids=("goal-a",),
+    )
+    with pytest.raises(TaskContractError, match="nonterminal child work"):
+        TaskContractCompiler().compile_batch(
+            (
+                PlannerTaskIntentV2(
+                    ScriptTaskKind.COMPOSE,
+                    "close too early",
+                    decision_ids=tuple(
+                        item.ref.decision_id for item in decisions
+                    ),
+                ),
+            ),
+            snapshot,
+            command_id="premature-compose",
+            budget=ScriptTaskBudget(),
+        )
+
+
+def test_paragraph_target_is_host_semantic_target_not_objective_text() -> None:
+    structure = _decision("structure-1", ScriptTaskKind.STRUCTURE, "branch-a")
+    snapshot = PlanningSnapshot(
+        root_trace_id="root",
+        phase=2,
+        containers=(
+            PlanningContainer(
+                "compose-parent",
+                ScriptTaskKind.COMPOSE,
+                None,
+                "script-build://branches/branch-a",
+                ("goal-a",),
+            ),
+        ),
+        decisions=(structure,),
+        targets=(
+            PlanningTarget(
+                "script-build://targets/paragraph-a",
+                structure.ref.decision_id,
+                ScriptTaskKind.STRUCTURE,
+                "script-build://branches/branch-a",
+                ("goal-a",),
+            ),
+        ),
+        active_direction_ref=_decision(
+            "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
+        ).ref,
+        direction_goal_ids=("goal-a",),
+    )
+    compiled = TaskContractCompiler().compile_batch(
+        (
+            PlannerTaskIntentV2(
+                ScriptTaskKind.PARAGRAPH,
+                "wording can change freely",
+                decision_ids=("structure-1",),
+                target_goal_ids=("goal-a",),
+            ),
+        ),
+        snapshot,
+        command_id="command",
+        budget=ScriptTaskBudget(),
+    )[0]
+    assert compiled.contract.workspace_seed is not None
+    assert (
+        compiled.contract.workspace_seed.semantic_target_ref
+        == "script-build://targets/paragraph-a"
+    )
+
+
+def test_ambiguous_semantic_target_fails_before_task_creation() -> None:
+    structure = _decision("structure-1", ScriptTaskKind.STRUCTURE, "branch-a")
+    targets = tuple(
+        PlanningTarget(
+            f"script-build://targets/{suffix}",
+            structure.ref.decision_id,
+            ScriptTaskKind.STRUCTURE,
+            "script-build://branches/branch-a",
+            ("goal-a",),
+        )
+        for suffix in ("a", "b")
+    )
+    snapshot = PlanningSnapshot(
+        "root",
+        2,
+        (
+            PlanningContainer(
+                "compose-parent",
+                ScriptTaskKind.COMPOSE,
+                None,
+                "script-build://branches/branch-a",
+                ("goal-a",),
+            ),
+        ),
+        (structure,),
+        targets,
+        _decision("direction-1", ScriptTaskKind.DIRECTION, "phase-one").ref,
+        ("goal-a",),
+    )
+    with pytest.raises(TaskContractError, match="semantic targets"):
+        TaskContractCompiler().compile_batch(
+            (
+                PlannerTaskIntentV2(
+                    ScriptTaskKind.PARAGRAPH,
+                    "paragraph",
+                    decision_ids=("structure-1",),
+                    target_goal_ids=("goal-a",),
+                ),
+            ),
+            snapshot,
+            command_id="command",
+            budget=ScriptTaskBudget(),
+        )
+
+
+def test_pattern_evidence_cannot_satisfy_missing_persona() -> None:
+    manifest = DirectionEvidenceRequirementFactory().compile(
+        topic_input_ref="input:topic",
+        requested_account_name="account",
+        persona_points=(),
+        persona_input_ref=None,
+        section_patterns=(),
+        section_pattern_input_ref=None,
+    )
+    closure = EvidenceClosureEvaluator().evaluate(
+        manifest,
+        (
+            EvidenceItem(
+                "direction.section_pattern",
+                "pattern:1",
+                EvidenceProvider.PATTERN,
+            ),
+        ),
+        gate=EvidenceGate.DIRECTION_ACCEPT,
+    )
+    assert "direction.persona" in closure.unsatisfied_hard_requirement_ids
+
+
+@pytest.mark.asyncio
+async def test_direction_gate_rejects_accept_from_stale_child_spec() -> None:
+    manifest = DirectionEvidenceRequirementFactory().compile(
+        topic_input_ref="input:topic",
+        requested_account_name="account",
+        persona_points=(),
+        persona_input_ref=None,
+        section_patterns=({"pattern": "hook"},),
+        section_pattern_input_ref="input:patterns",
+    )
+    direction = _decision(
+        "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
+    ).ref
+    portfolio = _decision(
+        "portfolio-1", ScriptTaskKind.CANDIDATE_PORTFOLIO, "portfolio"
+    ).ref
+    contract = TaskContractCompiler().compile_host_contract(
+        task_id="root-task",
+        parent_task_id="root-task",
+        task_kind=ScriptTaskKind.ROOT_DELIVERY,
+        objective="publish",
+        scope_ref=direction.scope_ref,
+        branch_ref="script-build://branches/root-delivery",
+        input_decision_refs=(direction, portfolio),
+        criteria=(ScriptCriterion("published", "publish", True),),
+        budget=ScriptTaskBudget(),
+        goal_ids=("goal-a",),
+        evidence_requirements=manifest,
+    )
+    child = SimpleNamespace(
+        task_id="evidence-child",
+        status=TaskStatus.COMPLETED,
+        current_spec_version=2,
+        current_spec=SimpleNamespace(
+            context_refs=("script-build://task-kinds/evidence-retrieval",)
+        ),
+        decision_ids=["accepted-old-spec"],
+    )
+    ledger = SimpleNamespace(
+        tasks={child.task_id: child},
+        decisions={
+            "accepted-old-spec": SimpleNamespace(
+                action=SimpleNamespace(value="accept"),
+                attempt_id="attempt-v1",
+            )
+        },
+        attempts={
+            "attempt-v1": SimpleNamespace(
+                attempt_id="attempt-v1",
+                spec_version=1,
+                submission=SimpleNamespace(evidence_refs=("evidence://persona",)),
+            )
+        },
+    )
+
+    class UnreadArtifacts:
+        async def read_by_ref(self, *_: object, **__: object) -> object:
+            raise AssertionError("stale accepted evidence must not be read")
+
+    with pytest.raises(RequiredEvidenceUnsatisfied):
+        await DirectionEvidenceGate(UnreadArtifacts()).require(
+            script_build_id=1,
+            ledger=ledger,
+            direction_task=SimpleNamespace(child_task_ids=(child.task_id,)),
+            contract=contract,
+            gate=EvidenceGate.DIRECTION_ACCEPT,
+        )

+ 9 - 14
script_build_host/tests/test_mission_factory.py

@@ -67,7 +67,7 @@ def test_factory_reads_nested_real_topic_and_does_not_double_prefix_digest() ->
     assert message["context_entrypoint"] == "get_current_context"
     assert "input_snapshot_ref" not in message
     assert "input_sha256" not in message
-    assert "Retrieval children under that Direction" in message["instruction"]
+    assert "logical evidence work" in message["instruction"]
     assert "exact reason PHASE_ONE_CAPABILITY_BOUNDARY" in message["instruction"]
     config = factory.build_run_config(_binding(), _snapshot())
     assert config.agent_type == "script_planner"
@@ -85,19 +85,14 @@ def test_phase_prompts_isolate_boundaries_and_expose_dynamic_phase_two_invariant
     factory = ScriptMissionFactory()
     planner_prompt = " ".join(PLANNER_PROMPT.split())
     assert "PHASE_TWO_CANDIDATE_PORTFOLIO_READY" not in PLANNER_PROMPT
-    assert "Direction 下创建并 ACCEPT 所需 Retrieval" in planner_prompt
-    assert "阶段策略给出的精确 reason" in planner_prompt
+    assert "自动创建 一个 logical evidence-retrieval Task" in planner_prompt
+    assert "阶段协议给出的精确 reason" in planner_prompt
     phase_two = factory.build_phase_two_policy(_snapshot())
     assert "PHASE_TWO_CANDIDATE_PORTFOLIO_READY" in phase_two
-    assert "Create exactly one CandidatePortfolio as Root's Phase2 child" in phase_two
-    assert "Plan any number and order" in phase_two
-    assert "Every Phase2 contract has nonempty goal_ids" in phase_two
-    assert "automatically pinned to the active accepted Direction" in phase_two
-    assert "carry all Direction Goal IDs" in phase_two
-    assert "Paragraph-first exploration may use a null base" in phase_two
-    assert "only with a Structure covering its scope" in phase_two
-    assert "selects accepted child Decision IDs" in phase_two
-    assert "Host partitions the complete candidate closure" in phase_two
-    assert "write scopes" in phase_two
-    assert "closure fields are Host-owned" in phase_two
+    assert "Submit business intents for Structure, Paragraph, ElementSet" in phase_two
+    assert "Host determines containers, branch identity, semantic targets" in phase_two
+    assert "exactly one accepted Structure, Paragraph and ElementSet" in phase_two
+    assert "CandidatePortfolio intent selects exactly one accepted Compose" in phase_two
+    assert "Do not create physical Retrieval Tasks" in phase_two
+    assert "scopes, artifact refs, presets, budgets or closure fields" in phase_two
     assert "不能从名称或 objective 猜测" in PARAGRAPH_WORKER_PROMPT

+ 27 - 1405
script_build_host/tests/test_phase_two_contracts.py

@@ -1,65 +1,23 @@
 from __future__ import annotations
 
-import asyncio
-from dataclasses import replace
 from datetime import UTC, datetime, timedelta
-from hashlib import sha256
-from pathlib import Path
 from types import SimpleNamespace
-from typing import Any, cast
 
 import pytest
-from agent import AgentRunner, FileSystemArtifactStore, FileSystemTaskStore, FileSystemTraceStore
-from agent.orchestration import (
-    AgentRole,
-    ArtifactRef,
-    AttemptSubmission,
-    CriterionResult,
-    DecisionAction,
-    OperationStatus,
-    OrchestrationConfig,
-    TaskCoordinator,
-    TaskStatus,
-    ValidationVerdict,
-)
-from agent.orchestration.protocols import ValidatorRunResult, WorkerRunResult
 
-from script_build_host.application.mission_factory import ScriptMissionFactory
-from script_build_host.application.mission_service import ScriptMissionService
 from script_build_host.application.phase_two_planning import (
+    PHASE_ONE_CAPABILITY_BOUNDARY,
     PhasePolicyGuard,
-    PhaseTwoPlanningService,
     _execution_seconds,
-    _write_scope_for,
 )
-from script_build_host.domain.artifacts import DirectionArtifact, DirectionGoal
-from script_build_host.domain.errors import ProtocolViolation
-from script_build_host.domain.records import BuildStatus, MissionBinding, Principal
-from script_build_host.domain.task_contracts import (
-    PhaseTwoLimits,
-    ScriptTaskContractV1,
-    ScriptTaskKind,
-    TaskContractError,
+from script_build_host.domain.task_capabilities import (
+    LegacyTaskCapabilityCatalog,
+    TaskCapabilityCatalog,
 )
-from script_build_host.infrastructure.task_contract_store import FileScriptTaskContractStore
+from script_build_host.domain.task_contracts import ScriptTaskKind, TaskContractError
 
 
-def test_element_set_owns_only_elements_even_when_host_materializes_paragraph_endpoints() -> None:
-    first = _write_scope_for(
-        ScriptTaskKind.ELEMENT_SET, scope_ref="script-build://scopes/compose/elements"
-    )
-    repeated = _write_scope_for(
-        ScriptTaskKind.ELEMENT_SET, scope_ref="script-build://scopes/compose/elements"
-    )
-    other = _write_scope_for(
-        ScriptTaskKind.ELEMENT_SET, scope_ref="script-build://scopes/other/elements"
-    )
-    assert first == repeated
-    assert first[0].startswith("script-build://writes/elements/scopes/")
-    assert first != other
-
-
-def test_execution_budget_counts_stage_intervals_not_planner_idle_time() -> None:
+def test_execution_budget_counts_runtime_intervals_not_planner_idle_time() -> None:
     now = datetime.now(UTC)
     stages = (
         SimpleNamespace(
@@ -75,1370 +33,34 @@ def test_execution_budget_counts_stage_intervals_not_planner_idle_time() -> None
     assert _execution_seconds(stages) == pytest.approx(20.0)
 
 
-def _contract(
-    kind: str,
-    *,
-    scope: str = "script-build://scopes/full",
-    execution_ready: bool = True,
-) -> dict[str, object]:
-    output = {
-        "direction": "script-direction/v1",
-        "candidate-portfolio": "candidate-portfolio/v1",
-        "compose": "structured-script/v1",
-        "paragraph": "paragraph-artifact/v1",
-        "element-set": "element-set-artifact/v1",
-        "structure": "structure-artifact/v1",
-        "compare": "comparison-artifact/v1",
-        "decode-retrieval": "evidence-record/v1",
-    }[kind]
-    payload: dict[str, object] = {
-        "schema_version": "script-task-contract/v1",
-        "compiler_manifest_digest": "sha256:" + "0" * 64,
-        "task_kind": kind,
-        "scope_ref": scope,
-        "intent_class": (
-            "portfolio"
-            if kind == "candidate-portfolio"
-            else "compose"
-            if kind == "compose"
-            else "explore"
-        ),
-        "objective": f"produce one bounded {kind} increment",
-        "input_decision_refs": [],
-        "base_artifact_ref": None,
-        "write_scope": [scope],
-        "gap_ref": None,
-        "output_schema": output,
-        "criteria": [
-            {
-                "criterion_id": "closed",
-                "description": "the bounded increment is independently verifiable",
-                "hard": True,
-            }
-        ],
-        "budget": {
-            "max_attempts": 4,
-            "max_tokens": 32000,
-            "max_seconds": 900,
-            "max_external_queries": 40,
-            "max_no_improvement": 3,
-        },
-        "goal_ids": ([] if kind in {"direction", "decode-retrieval"} else ["goal-1"]),
-        "supersedes_decision_ids": [],
-        "candidate_closure_decision_refs": [],
-        "adopted_decision_ids": [],
-        "held_or_rejected_decision_ids": [],
-        "compose_order": [],
-        "comparison_decision_refs": [],
-    }
-    if execution_ready and kind in {"compose", "candidate-portfolio"}:
-        ref = {
-            "decision_id": "decision-1",
-            "artifact_ref": {
-                "uri": "script-build://artifact-versions/1",
-                "kind": "structured_script" if kind == "candidate-portfolio" else "paragraph",
-                "version": "1",
-                "digest": "sha256:" + "a" * 64,
-            },
-            "scope_ref": scope,
-            "expected_task_kind": "compose" if kind == "candidate-portfolio" else "paragraph",
-        }
-        payload["candidate_closure_decision_refs"] = [ref]
-        payload["adopted_decision_ids"] = ["decision-1"]
-        payload["compose_order"] = ["decision-1"]
-    return payload
-
-
-def _planner_input(payload: dict[str, object]) -> dict[str, object]:
-    input_refs = cast(list[dict[str, Any]], payload.get("input_decision_refs") or [])
-    comparison_refs = cast(list[dict[str, Any]], payload.get("comparison_decision_refs") or [])
-    base = cast(dict[str, Any] | None, payload.get("base_artifact_ref"))
-    scope = str(payload["scope_ref"])
-    prefix = "script-build://scopes/"
-    path = scope.removeprefix(prefix).split("/") if scope.startswith(prefix) else ["full"]
-    return {
-        "task_kind": payload["task_kind"],
-        "scope_selector": {"anchor": "mission", "path": path[:4]},
-        "intent_class": payload["intent_class"],
-        "objective": payload["objective"],
-        "goal_ids": payload["goal_ids"],
-        "criteria": (
-            [
-                {
-                    "client_key": "evidence-present",
-                    "criterion_type": "evidence_nonempty",
-                    "params": {"minimum_count": 1},
-                    "hard": True,
-                }
-            ]
-            if str(payload["task_kind"]).endswith("retrieval")
-            else [
-                {
-                    "client_key": item["criterion_id"],
-                    "criterion_type": "custom",
-                    "description": item["description"],
-                    "hard": item.get("hard", True),
-                }
-                for item in cast(list[dict[str, Any]], payload["criteria"])
-            ]
-        ),
-        "input_decision_ids": [item["decision_id"] for item in input_refs],
-        "base_decision_id": next(
-            (
-                item["decision_id"]
-                for item in input_refs
-                if base is not None and item["artifact_ref"]["uri"] == base["uri"]
-            ),
-            None,
-        ),
-        "comparison_decision_ids": [item["decision_id"] for item in comparison_refs],
-        "supersedes_decision_ids": payload["supersedes_decision_ids"],
-        "gap_key": (
-            str(payload["gap_ref"]).rsplit("/", 1)[-1]
-            if payload.get("gap_ref") is not None
-            else None
-        ),
-    }
-
-
-def test_deep_control_uri_is_not_mistaken_for_base64_content() -> None:
-    scope = (
-        "script-build://mission/900032/direction/main/portfolio/main/compose/main/"
-        "structure/main/paragraph/slot1"
-    )
-    contract = ScriptTaskContractV1.from_payload(_contract("paragraph", scope=scope))
-    assert contract.scope_ref == scope
-
-    encoded = _contract("paragraph", scope="script-build://scopes/" + "A" * 80)
-    with pytest.raises(TaskContractError, match="encoded candidate content"):
-        ScriptTaskContractV1.from_payload(encoded)
-
-
-def test_phase_policy_rejects_workspace_contract_without_capability_write_scope() -> None:
-    guard = PhasePolicyGuard()
-    ledger = SimpleNamespace(root_task_id="root")
-    parent = SimpleNamespace(
-        task_id="compose",
-        current_spec=SimpleNamespace(context_refs=("script-build://task-kinds/compose",)),
-    )
-    invalid = ScriptTaskContractV1.from_payload(_contract("structure"))
-    with pytest.raises(TaskContractError, match="WRITE_SCOPE_VIOLATION"):
-        guard.contracts(context={"phase": 2}, ledger=ledger, parent=parent, contracts=(invalid,))
-
-    payload = _contract("structure")
-    payload["write_scope"] = ["script-build://writes/paragraphs/main"]
-    valid = ScriptTaskContractV1.from_payload(payload)
-    guard.contracts(context={"phase": 2}, ledger=ledger, parent=parent, contracts=(valid,))
-
-
-def test_phase_policy_requires_broad_container_write_capability() -> None:
-    guard = PhasePolicyGuard()
-    ledger = SimpleNamespace(root_task_id="root")
-    root = SimpleNamespace(task_id="root", current_spec=SimpleNamespace(context_refs=()))
-    invalid = ScriptTaskContractV1.from_payload(
-        _contract("candidate-portfolio", execution_ready=False)
-    )
-    with pytest.raises(TaskContractError, match="WRITE_SCOPE_VIOLATION"):
-        guard.contracts(context={"phase": 2}, ledger=ledger, parent=root, contracts=(invalid,))
-
-    payload = _contract("candidate-portfolio", execution_ready=False)
-    payload["write_scope"] = ["script-build://writes"]
-    valid = ScriptTaskContractV1.from_payload(payload)
-    guard.contracts(context={"phase": 2}, ledger=ledger, parent=root, contracts=(valid,))
-
-
-def test_phase_policy_requires_structure_base_and_compose_coverage() -> None:
+def test_phase_policy_dispatches_only_v2_logical_evidence() -> None:
     guard = PhasePolicyGuard()
-    structure_ref = {
-        "decision_id": "structure-decision",
-        "artifact_ref": {
-            "uri": "script-build://artifact-versions/10",
-            "kind": "structure",
-            "version": "10",
-            "digest": "sha256:" + "a" * 64,
-        },
-        "scope_ref": "script-build://scopes/full/structure",
-        "expected_task_kind": "structure",
-    }
-    paragraph_ref = {
-        "decision_id": "paragraph-decision",
-        "artifact_ref": {
-            "uri": "script-build://artifact-versions/11",
-            "kind": "paragraph",
-            "version": "11",
-            "digest": "sha256:" + "b" * 64,
-        },
-        "scope_ref": "script-build://scopes/full/structure/paragraph",
-        "expected_task_kind": "paragraph",
-    }
-
-    paragraph = _contract("paragraph")
-    paragraph["write_scope"] = ["script-build://writes/paragraphs/main"]
-
-    missing_structure_input = ScriptTaskContractV1.from_payload(paragraph)
-    guard.revisions(context={"phase": 2}, contracts=(missing_structure_input,))
-
-    paragraph["base_artifact_ref"] = structure_ref["artifact_ref"]
-    invalid_paragraph = ScriptTaskContractV1.from_payload(paragraph)
-    with pytest.raises(TaskContractError, match="accepted Structure decision"):
-        guard.revisions(context={"phase": 2}, contracts=(invalid_paragraph,))
-
-    paragraph["input_decision_refs"] = [structure_ref]
-    paragraph["base_artifact_ref"] = {
-        **structure_ref["artifact_ref"],
-        "digest": "sha256:" + "f" * 64,
-    }
-    reconstructed_paragraph = ScriptTaskContractV1.from_payload(paragraph)
-    with pytest.raises(TaskContractError, match="unchanged"):
-        guard.revisions(context={"phase": 2}, contracts=(reconstructed_paragraph,))
-
-    paragraph["base_artifact_ref"] = structure_ref["artifact_ref"]
-    valid_paragraph = ScriptTaskContractV1.from_payload(paragraph)
-    guard.revisions(context={"phase": 2}, contracts=(valid_paragraph,))
-
-    compose = _contract("compose", execution_ready=False)
-    compose["write_scope"] = ["script-build://writes"]
-    guard.revisions(
-        context={"phase": 2},
-        contracts=(ScriptTaskContractV1.from_payload(compose),),
-    )
-    compose["candidate_closure_decision_refs"] = [paragraph_ref]
-    compose["adopted_decision_ids"] = ["paragraph-decision"]
-    compose["compose_order"] = ["paragraph-decision"]
-    with pytest.raises(TaskContractError, match="accepted Direction"):
-        guard.revisions(
-            context={"phase": 2},
-            contracts=(ScriptTaskContractV1.from_payload(compose),),
-        )
-    direction_ref = {
-        "decision_id": "direction-decision",
-        "artifact_ref": {
-            "uri": "script-build://artifact-versions/9",
-            "kind": "direction",
-            "version": "9",
-            "digest": "sha256:" + "d" * 64,
-        },
-        "scope_ref": "script-build://scopes/full",
-        "expected_task_kind": "direction",
-    }
-    compose["input_decision_refs"] = [direction_ref]
-    invalid_compose = ScriptTaskContractV1.from_payload(compose)
-    with pytest.raises(TaskContractError, match="covering Structure"):
-        guard.revisions(context={"phase": 2}, contracts=(invalid_compose,))
-
-    compose["candidate_closure_decision_refs"] = [structure_ref, paragraph_ref]
-    compose["adopted_decision_ids"] = ["structure-decision", "paragraph-decision"]
-    compose["compose_order"] = ["structure-decision", "paragraph-decision"]
-    valid_compose = ScriptTaskContractV1.from_payload(compose)
-    guard.revisions(context={"phase": 2}, contracts=(valid_compose,))
-
-
-class _Bindings:
-    def __init__(self, root: str) -> None:
-        self.binding = MissionBinding(
-            binding_id=1,
-            script_build_id=7,
-            root_trace_id=root,
-            input_snapshot_id=11,
-            active_direction_artifact_version_id=None,
-            accepted_root_artifact_version_id=None,
-            engine_version="test",
-            schema_version="v1",
-            created_at=datetime.now(UTC),
-            updated_at=datetime.now(UTC),
-        )
-
-    async def get_by_root(self, root: str) -> MissionBinding:
-        assert root == self.binding.root_trace_id
-        return self.binding
-
-    async def get_by_build(self, script_build_id: int) -> MissionBinding:
-        assert script_build_id == self.binding.script_build_id
-        return self.binding
-
-
-class _DirectionArtifacts:
-    async def get_by_id(self, identifier: int, *, script_build_id: int) -> Any:
-        assert script_build_id == 7
-        return SimpleNamespace(
-            artifact_version_id=identifier,
-            artifact=DirectionArtifact(
-                goals=(
-                    DirectionGoal(
-                        goal_id="goal-1",
-                        statement="complete the script",
-                        rationale="the mission needs one complete result",
-                        success_criteria=("the result is complete",),
-                    ),
-                ),
-                evidence_refs=("script-build://artifact-versions/evidence",),
-            ),
-        )
-
-
-class _PlaceholderThenReplacementExecutor:
-    """Exercise Coordinator state transitions without bypassing durable operations."""
-
-    def __init__(self, coordinator: TaskCoordinator) -> None:
-        self.coordinator = coordinator
-        self.artifact_version = 100
-
-    async def run_worker(self, context: dict[str, Any]) -> WorkerRunResult:
-        refs = cast(dict[str, Any], context["task_spec"])["context_refs"]
-        kind = next(item.rsplit("/", 1)[-1] for item in refs if "/task-kinds/" in item)
-        artifact_kind = {
-            "direction": "direction",
-            "compose": "structured_script",
-            "structure": "structure",
-            "paragraph": "paragraph",
-            "element-set": "element_set",
-        }[kind]
-        digest = "sha256:" + sha256(context["attempt_id"].encode()).hexdigest()
-        version = str(self.artifact_version)
-        self.artifact_version += 1
-        ref = ArtifactRef(
-            f"script-build://artifact-versions/{version}",
-            artifact_kind,
-            version,
-            digest,
-        )
-        await self.coordinator.submit_attempt(
-            {
-                "role": AgentRole.WORKER.value,
-                "root_trace_id": context["root_trace_id"],
-                "task_id": context["task_id"],
-                "attempt_id": context["attempt_id"],
-                "spec_version": context["spec_version"],
-                "trace_id": context["worker_trace_id"],
-                "tool_call_id": f"submit:{context['attempt_id']}",
-                "operation_id": context["operation_id"],
-                "execution_epoch": context["execution_epoch"],
-            },
-            AttemptSubmission(
-                summary=f"kind={artifact_kind};status=frozen",
-                artifact_refs=[ref],
-            ),
-        )
-        return WorkerRunResult(context["worker_trace_id"], "completed")
-
-    async def run_validator(self, context: dict[str, Any]) -> ValidatorRunResult:
-        refs = cast(dict[str, Any], context["task_spec"])["context_refs"]
-        kind = next(item.rsplit("/", 1)[-1] for item in refs if "/task-kinds/" in item)
-        placeholder = kind == "compose" and context["spec_version"] == 2
-        verdict = ValidationVerdict.FAILED if placeholder else ValidationVerdict.PASSED
-        criteria = cast(dict[str, Any], context["task_spec"])["acceptance_criteria"]
-        await self.coordinator.submit_validation(
-            {
-                "role": AgentRole.VALIDATOR.value,
-                "root_trace_id": context["root_trace_id"],
-                "task_id": context["task_id"],
-                "attempt_id": context["attempt_id"],
-                "validation_id": context["validation_id"],
-                "snapshot_id": context["snapshot_id"],
-                "trace_id": context["validator_trace_id"],
-                "tool_call_id": f"validate:{context['validation_id']}",
-                "operation_id": context["operation_id"],
-                "execution_epoch": context["execution_epoch"],
-            },
-            verdict,
-            [
-                CriterionResult(
-                    criterion_id=criterion["criterion_id"],
-                    verdict=verdict,
-                    reason=(
-                        "REALIZATION_PLACEHOLDER at artifact.paragraphs[0].description"
-                        if placeholder
-                        else "the replacement is concretely rendered"
-                    ),
-                )
-                for criterion in criteria
-            ],
-            (
-                "hard_defects=1;defect_codes=REALIZATION_PLACEHOLDER"
-                if placeholder
-                else "hard_defects=0"
-            ),
-            (),
-            ("artifact.paragraphs[0].description",) if placeholder else (),
-            ("REALIZATION_PLACEHOLDER",) if placeholder else (),
-            "split" if placeholder else "accept",
-        )
-        return ValidatorRunResult(context["validator_trace_id"], "completed")
-
-    async def stop(self, trace_id: str) -> bool:
-        del trace_id
-        return True
-
-
-async def _service(
-    tmp_path: Path, *, active_direction: bool = True
-) -> tuple[PhaseTwoPlanningService, TaskCoordinator, str]:
-    root = "root-contracts"
-    coordinator = TaskCoordinator(
-        FileSystemTaskStore(str(tmp_path / "ledger")),
-        FileSystemArtifactStore(str(tmp_path / "snapshots")),
-        FileSystemTraceStore(str(tmp_path / "traces")),
-    )
-    await coordinator.ensure_ledger(
-        root,
-        {
-            "objective": "build a script",
-            "acceptance_criteria": [
-                {"criterion_id": "ready", "description": "ready", "hard": True}
-            ],
-            "context_refs": ["script-build://inputs/11"],
-        },
-    )
-    bindings = _Bindings(root)
-    service = PhaseTwoPlanningService(
-        coordinator=coordinator,
-        bindings=bindings,
-        contracts=FileScriptTaskContractStore(tmp_path / "data"),
-        artifacts=cast(Any, _DirectionArtifacts()),
-    )
-    if active_direction:
-        await _activate_direction(service, coordinator, bindings, root)
-    return service, coordinator, root
-
-
-async def _activate_direction(
-    service: PhaseTwoPlanningService,
-    coordinator: TaskCoordinator,
-    bindings: _Bindings,
-    root: str,
-) -> None:
-    coordinator.set_executor(_PlaceholderThenReplacementExecutor(coordinator))
-    planned = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("direction"))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "fixture:direction"},
-    )
-    direction_id = cast(str, planned["task_ids"][0])
-    cycle = (
-        await service.dispatch_script_tasks(
-            task_ids=(direction_id,),
-            context={"root_trace_id": root, "tool_call_id": "fixture:dispatch-direction"},
-        )
-    )[0]
-    accepted = await service.decide_script_task(
-        task_id=direction_id,
-        action=DecisionAction.ACCEPT.value,
-        reason="fixture Direction passed",
-        validation_id=None,
-        replacement_contract=None,
-        child_contracts=(),
-        context={"root_trace_id": root, "tool_call_id": "fixture:accept-direction"},
-    )
-    ledger = await coordinator.task_store.load(root)
-    decision = ledger.decisions[cast(str, accepted["decision_id"])]
-    assert decision.validation_id == cycle["validation_id"]
-    attempt = ledger.attempts[cast(str, decision.attempt_id)]
-    assert attempt.submission is not None
-    direction_ref = attempt.submission.artifact_refs[0]
-    bindings.binding = replace(
-        bindings.binding,
-        active_direction_artifact_version_id=int(direction_ref.version),
-    )
-
-
-@pytest.mark.asyncio
-async def test_plan_inspection_returns_active_frontier_and_compact_accept_index(
-    tmp_path: Path,
-) -> None:
-    service, _, root = await _service(tmp_path)
-    view = await service.inspect_script_plan(context={"root_trace_id": root})
-
-    assert "tasks" not in view
-    assert view["root"]["status"] == TaskStatus.NEEDS_REPLAN.value
-    assert [item["task_kind"] for item in view["active_tasks"]] == ["root"]
-    assert len(view["accepted_decisions"]) == 1
-    accepted = view["accepted_decisions"][0]
-    assert accepted["task_kind"] == "direction"
-    assert accepted["artifact_kind"] == "direction"
-    assert "digest" not in accepted
-    assert view["counts_by_status"] == {
-        TaskStatus.NEEDS_REPLAN.value: 1,
-        TaskStatus.COMPLETED.value: 1,
-    }
-
-
-@pytest.mark.asyncio
-async def test_dispatch_explains_that_blocked_children_do_not_close_parent(
-    tmp_path: Path,
-) -> None:
-    service, coordinator, root = await _service(tmp_path, active_direction=False)
-    direction = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("direction"))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "direction"},
-    )
-    direction_id = direction["task_ids"][0]
-    retrieval = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("decode-retrieval"))],
-        parent_task_id=direction_id,
-        context={"root_trace_id": root, "tool_call_id": "retrieval"},
-    )
-    child_id = retrieval["task_ids"][0]
-    await coordinator.decide_task(
-        root,
-        child_id,
-        None,
-        DecisionAction.BLOCK,
-        {"reason": "unusable evidence"},
-        "block-child",
-    )
-
-    with pytest.raises(TaskContractError, match="BLOCKED is resumable"):
-        await service.dispatch_script_tasks(
-            task_ids=[direction_id],
-            context={"root_trace_id": root, "tool_call_id": "dispatch-parent"},
-        )
-
-
-@pytest.mark.asyncio
-async def test_phase_one_direction_requires_an_accepted_retrieval_before_dispatch(
-    tmp_path: Path,
-) -> None:
-    service, _coordinator, root = await _service(tmp_path, active_direction=False)
-    context = {"root_trace_id": root, "phase": 1}
-    direction = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("direction"))],
-        parent_task_id=None,
-        context={**context, "tool_call_id": "direction"},
-    )
-    await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("decode-retrieval"))],
-        parent_task_id=direction["task_ids"][0],
-        context={**context, "tool_call_id": "retrieval"},
-    )
-
-    with pytest.raises(TaskContractError, match="Retrieval ACCEPT"):
-        await service.dispatch_script_tasks(
-            task_ids=[direction["task_ids"][0]],
-            context={**context, "tool_call_id": "dispatch-direction"},
-        )
-
-
-@pytest.mark.asyncio
-async def test_unique_portfolio_container_cannot_be_cancelled(tmp_path: Path) -> None:
-    service, _coordinator, root = await _service(tmp_path)
-    portfolio = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "portfolio"},
-    )
-
-    with pytest.raises(TaskContractError, match="cannot be cancelled"):
-        await service.decide_script_task(
-            task_id=portfolio["task_ids"][0],
-            action="cancel",
-            reason="wrong structural recovery",
-            validation_id=None,
-            replacement_contract=None,
-            child_contracts=(),
-            context={"root_trace_id": root, "tool_call_id": "cancel-portfolio"},
-        )
-
-
-@pytest.mark.asyncio
-async def test_accepted_input_scope_is_rejected_before_worker_dispatch(tmp_path: Path) -> None:
-    service, _coordinator, root = await _service(tmp_path)
-    structure_scope = "script-build://direction/main/compose/candidate-1/structure/main"
-    structure = ScriptTaskContractV1.from_payload(_contract("structure", scope=structure_scope))
-    paragraph_payload = _contract(
-        "paragraph",
-        scope="script-build://direction/main/compose/candidate-2/paragraph/p1",
-    )
-    accepted_ref = {
-        "decision_id": "accepted-structure",
-        "artifact_ref": {
-            "uri": "script-build://artifact-versions/1",
-            "kind": "structure",
-            "version": "1",
-            "digest": "sha256:" + "a" * 64,
-        },
-        "scope_ref": structure_scope,
-        "expected_task_kind": "structure",
-    }
-    paragraph_payload["input_decision_refs"] = [accepted_ref]
-    paragraph_payload["base_artifact_ref"] = accepted_ref["artifact_ref"]
-    paragraph = ScriptTaskContractV1.from_payload(paragraph_payload)
-    producer_task = SimpleNamespace(task_id="structure-task", parent_task_id="compose-task")
-    ledger = SimpleNamespace(
-        decisions={
-            "accepted-structure": SimpleNamespace(
-                action=DecisionAction.ACCEPT,
-                attempt_id="structure-attempt",
-                task_id=producer_task.task_id,
-            )
-        },
-        tasks={producer_task.task_id: producer_task},
-    )
-
-    async def frozen_contract(_root_trace_id: str, task: Any) -> Any:
-        assert task is producer_task
-        return SimpleNamespace(contract=structure)
-
-    service.contract_for_task = frozen_contract  # type: ignore[method-assign]
-
-    with pytest.raises(TaskContractError, match="INPUT_SCOPE_MISMATCH") as raised:
-        await service._guard_accepted_input_scopes(root, ledger, (paragraph,))
-    assert raised.value.details == {
-        "decision_id": "accepted-structure",
-        "producer_task_id": "structure-task",
-        "producer_parent_task_id": "compose-task",
-        "consumer_kind": "paragraph",
-        "consumer_scope": paragraph.scope_ref,
-        "producer_kind": "structure",
-        "producer_scope": structure.scope_ref,
-        "source": "explicit",
-        "recovery": (
-            "create the consumer in the producer's creative branch, normally as a sibling under "
-            "producer_parent_task_id; changing input_decision_ids to base_decision_id does not "
-            "change scope"
-        ),
-    }
-
-
-@pytest.mark.asyncio
-async def test_overlapping_write_scopes_are_dispatched_serially(tmp_path: Path) -> None:
-    service, _coordinator, root = await _service(tmp_path)
-    active = SimpleNamespace(task_id="active", parent_task_id="compose-a")
-    requested = SimpleNamespace(task_id="requested", parent_task_id="compose-b")
-    ledger = SimpleNamespace(
-        tasks={active.task_id: active, requested.task_id: requested},
-        operations={
-            "running": SimpleNamespace(
-                status=OperationStatus.RUNNING,
-                task_ids=[active.task_id],
-            )
-        },
-    )
-    active_payload = _contract("structure", scope="script-build://scopes/compose/structure")
-    active_payload["write_scope"] = ["script-build://writes/paragraphs"]
-    requested_payload = _contract(
-        "paragraph", scope="script-build://scopes/compose/paragraph"
-    )
-    requested_payload["write_scope"] = ["script-build://writes/paragraphs/scopes/p1"]
-    contracts = {
-        active.task_id: ScriptTaskContractV1.from_payload(active_payload),
-        requested.task_id: ScriptTaskContractV1.from_payload(requested_payload),
-    }
-
-    async def frozen_contract(_root_trace_id: str, task: Any) -> Any:
-        return SimpleNamespace(contract=contracts[task.task_id])
-
-    service.contract_for_task = frozen_contract  # type: ignore[method-assign]
-
-    with pytest.raises(TaskContractError, match="dispatched serially") as raised:
-        await service._guard_concurrent_write_scopes(root, ledger, [requested.task_id])
-    assert raised.value.code == "TASK_STATE_CONFLICT"
-
-
-@pytest.mark.asyncio
-async def test_candidate_workspace_writers_are_serial_even_with_distinct_scopes(
-    tmp_path: Path,
-) -> None:
-    service, _coordinator, root = await _service(tmp_path)
-    active = SimpleNamespace(task_id="active", parent_task_id="compose")
-    requested = SimpleNamespace(task_id="requested", parent_task_id="compose")
-    ledger = SimpleNamespace(
-        tasks={active.task_id: active, requested.task_id: requested},
-        operations={
-            "running": SimpleNamespace(
-                status=OperationStatus.RUNNING,
-                task_ids=[active.task_id],
-            )
-        },
-    )
-    active_payload = _contract("paragraph", scope="script-build://scopes/compose/p1")
-    active_payload["write_scope"] = ["script-build://writes/paragraphs/scopes/p1"]
-    requested_payload = _contract("paragraph", scope="script-build://scopes/compose/p2")
-    requested_payload["write_scope"] = ["script-build://writes/paragraphs/scopes/p2"]
-    contracts = {
-        active.task_id: ScriptTaskContractV1.from_payload(active_payload),
-        requested.task_id: ScriptTaskContractV1.from_payload(requested_payload),
-    }
-
-    async def frozen_contract(_root_trace_id: str, task: Any) -> Any:
-        return SimpleNamespace(contract=contracts[task.task_id])
-
-    service.contract_for_task = frozen_contract  # type: ignore[method-assign]
-
-    with pytest.raises(TaskContractError, match="candidate workspace"):
-        await service._guard_concurrent_write_scopes(root, ledger, [requested.task_id])
-
-
-class _BlockingExecutor:
-    def __init__(self) -> None:
-        self.started = asyncio.Event()
-        self.stopped_trace_ids: list[str] = []
+    guard.dispatch(context={"phase": 1}, kind=ScriptTaskKind.DIRECTION)
+    guard.dispatch(context={"phase": 1}, kind=ScriptTaskKind.EVIDENCE_RETRIEVAL)
 
-    async def run_worker(self, context: dict[str, Any]) -> WorkerRunResult:
-        self.started.set()
-        await asyncio.Event().wait()
-        raise AssertionError(f"worker unexpectedly resumed: {context['attempt_id']}")
-
-    async def run_validator(self, context: dict[str, Any]) -> ValidatorRunResult:
-        raise AssertionError(f"validator must not start: {context['validation_id']}")
-
-    async def stop(self, trace_id: str) -> bool:
-        self.stopped_trace_ids.append(trace_id)
-        return True
-
-
-class _StopState:
-    def __init__(self) -> None:
-        self.status = BuildStatus.RUNNING
-        self.error_summary: str | None = None
-
-    async def get_status(self, script_build_id: int) -> BuildStatus:
-        assert script_build_id == 7
-        return self.status
-
-    async def set_status(
-        self,
-        script_build_id: int,
-        status: BuildStatus,
-        *,
-        error_summary: str | None = None,
-    ) -> None:
-        assert script_build_id == 7
-        self.status = status
-        self.error_summary = error_summary
-
-
-@pytest.mark.asyncio
-async def test_real_operation_stop_converges_and_forbids_new_dispatch(tmp_path: Path) -> None:
-    root = "root-stop-operation"
-    trace_store = FileSystemTraceStore(str(tmp_path / "stop-traces"))
-    coordinator = TaskCoordinator(
-        FileSystemTaskStore(str(tmp_path / "stop-ledger")),
-        FileSystemArtifactStore(str(tmp_path / "stop-artifacts")),
-        trace_store,
-        config=OrchestrationConfig(stop_grace_seconds=0.01),
-    )
-    await coordinator.ensure_ledger(
-        root,
-        {
-            "objective": "build a script",
-            "acceptance_criteria": [
-                {"criterion_id": "ready", "description": "ready", "hard": True}
-            ],
-            "context_refs": ["script-build://inputs/11"],
-        },
-    )
-    bindings = _Bindings(root)
-    planning = PhaseTwoPlanningService(
-        coordinator=coordinator,
-        bindings=bindings,
-        contracts=FileScriptTaskContractStore(tmp_path / "stop-contracts"),
-        artifacts=cast(Any, _DirectionArtifacts()),
-    )
-    await _activate_direction(planning, coordinator, bindings, root)
-    portfolio = await planning.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "stop:portfolio"},
-    )
-    compose = await planning.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("compose", execution_ready=False))],
-        parent_task_id=portfolio["task_ids"][0],
-        context={"root_trace_id": root, "tool_call_id": "stop:compose"},
-    )
-    paragraph = await planning.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("paragraph"))],
-        parent_task_id=compose["task_ids"][0],
-        context={"root_trace_id": root, "tool_call_id": "stop:paragraph"},
-    )
-    executor = _BlockingExecutor()
-    coordinator.set_executor(executor)
-
-    async def unused_llm(**_kwargs: Any) -> Any:
-        raise AssertionError("Planner LLM must not run during operation stop")
-
-    runner = AgentRunner(trace_store=trace_store, llm_call=unused_llm)
-    state = _StopState()
-
-    class Authorizer:
-        async def require_access(self, principal: Principal, script_build_id: int) -> None:
-            assert principal.subject == "owner" and script_build_id == 7
-
-    mission = ScriptMissionService(
-        runner=runner,
-        coordinator=coordinator,
-        factory=ScriptMissionFactory(),
-        input_snapshots=SimpleNamespace(),
-        bindings=bindings,
-        legacy_state=state,
-        authorizer=Authorizer(),
-        direction_reconciler=SimpleNamespace(),
-        stop_timeout_seconds=1,
-    )
-    planning.dispatch_guard = mission
-    dispatch = asyncio.create_task(
-        planning.dispatch_script_tasks(
-            task_ids=paragraph["task_ids"],
-            context={"root_trace_id": root, "tool_call_id": "stop:dispatch"},
-        )
-    )
-    await asyncio.wait_for(executor.started.wait(), timeout=1)
-
-    stopped = await mission.stop(7, Principal("owner"))
-    dispatch_result = await asyncio.gather(dispatch, return_exceptions=True)
-
-    assert stopped.status is BuildStatus.STOPPED
-    assert len(stopped.stopped_operation_ids) == 1
-    assert executor.stopped_trace_ids
-    assert not isinstance(dispatch_result[0], asyncio.CancelledError)
-    ledger = await coordinator.task_store.load(root)
-    operation = ledger.operations[stopped.stopped_operation_ids[0]]
-    assert operation.status is OperationStatus.STOPPED
-    assert ledger.tasks[paragraph["task_ids"][0]].status is TaskStatus.NEEDS_REPLAN
-    assert (await mission.stop(7, Principal("owner"))).status is BuildStatus.STOPPED
-
-    with pytest.raises(ProtocolViolation, match="forbidden after stop intent"):
-        await planning.dispatch_script_tasks(
-            task_ids=paragraph["task_ids"],
-            context={"root_trace_id": root, "tool_call_id": "stop:late-dispatch"},
-        )
-
-
-@pytest.mark.asyncio
-async def test_contract_store_is_content_addressed_and_detects_tampering(tmp_path: Path) -> None:
-    store = FileScriptTaskContractStore(tmp_path)
-    from script_build_host.domain.task_contracts import ScriptTaskContractV1
-
-    contract = ScriptTaskContractV1.from_payload(_contract("paragraph"))
-    first = await store.freeze("root-a", contract)
-    second = await store.freeze("root-a", contract)
-    assert first == second
-    path = tmp_path / "script-task-contracts" / "root-a" / "sha256" / first.uri.rsplit("/", 1)[-1]
-    path.write_bytes(b"{}")
-    with pytest.raises(TaskContractError, match="TASK_CONTRACT_DIGEST_MISMATCH"):
-        await store.read("root-a", first.uri)
-    assert not await store.verify("root-a", first.uri, first.digest)
-
-
-@pytest.mark.asyncio
-async def test_orphan_contract_reclamation_uses_only_ledger_references(tmp_path: Path) -> None:
-    service, _, root = await _service(tmp_path)
-    referenced = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "portfolio"},
-    )
-    orphan = await service.contracts.freeze(
-        root, ScriptTaskContractV1.from_payload(_contract("paragraph"))
-    )
-    removed = await service.reclaim_orphan_contracts(root_trace_id=root)
-    assert removed == (orphan.uri,)
-    assert await service.contracts.verify(
-        root,
-        referenced["contracts"][0]["contract_ref"],
-        referenced["contracts"][0]["contract_digest"],
-    )
-    assert not await service.contracts.verify(root, orphan.uri, orphan.digest)
-
-
-@pytest.mark.asyncio
-async def test_failed_operation_discards_workspace_but_recovery_required_preserves_it(
-    tmp_path: Path,
-) -> None:
-    service, _, root = await _service(tmp_path)
-
-    class Lifecycle:
-        def __init__(self) -> None:
-            self.calls: list[dict[str, str]] = []
-
-        async def discard_attempt(self, **values: str) -> None:
-            self.calls.append(values)
-
-    lifecycle = Lifecycle()
-    service.workspace_lifecycle = lifecycle
-    attempts = {
-        "failed-attempt": SimpleNamespace(attempt_id="failed-attempt", task_id="paragraph-task"),
-        "recovery-attempt": SimpleNamespace(
-            attempt_id="recovery-attempt", task_id="paragraph-task"
-        ),
-    }
-    ledger = SimpleNamespace(
-        attempts=attempts,
-        operations={
-            "failed": SimpleNamespace(
-                status=OperationStatus.FAILED,
-                error="worker failed before submit",
-                attempt_ids=["failed-attempt"],
-            ),
-            "recovery": SimpleNamespace(
-                status=OperationStatus.FAILED,
-                error="MISSION_RECOVERY_REQUIRED",
-                attempt_ids=["recovery-attempt"],
-            ),
-        },
-    )
-    await service._reconcile_terminal_workspaces(root, ledger)
-    assert lifecycle.calls == [
-        {
-            "root_trace_id": root,
-            "task_id": "paragraph-task",
-            "attempt_id": "failed-attempt",
-            "reason": "worker failed before submit",
-        }
-    ]
-
-
-def test_comparison_refs_are_kind_specific_and_limits_cannot_be_raised() -> None:
-    candidate_refs = []
-    for index, kind in ((1, "paragraph"), (2, "structure")):
-        candidate_refs.append(
-            {
-                "decision_id": f"decision-{index}",
-                "artifact_ref": {
-                    "uri": f"script-build://artifact-versions/{index}",
-                    "kind": kind,
-                    "version": str(index),
-                    "digest": "sha256:" + str(index) * 64,
-                },
-                "scope_ref": "script-build://scopes/full",
-                "expected_task_kind": kind,
-            }
-        )
-    compare = _contract("compare")
-    compare["comparison_decision_refs"] = candidate_refs
-    assert ScriptTaskContractV1.from_payload(compare).task_kind.value == "compare"
-
-    comparison_ref = {
-        "decision_id": "decision-compare",
-        "artifact_ref": {
-            "uri": "script-build://artifact-versions/3",
-            "kind": "comparison",
-            "version": "3",
-            "digest": "sha256:" + "3" * 64,
-        },
-        "scope_ref": "script-build://scopes/full",
-        "expected_task_kind": "compare",
-    }
-    compose = _contract("compose")
-    compose["comparison_decision_refs"] = [comparison_ref]
-    ScriptTaskContractV1.from_payload(compose)
-    portfolio = _contract("candidate-portfolio")
-    portfolio["comparison_decision_refs"] = [comparison_ref]
-    with pytest.raises(TaskContractError, match="comparison_decision_refs"):
-        ScriptTaskContractV1.from_payload(portfolio)
-    with pytest.raises(TaskContractError, match="TASK_BUDGET_EXCEEDED"):
-        PhaseTwoLimits(max_tasks=65)
-
-
-@pytest.mark.asyncio
-async def test_planning_freezes_contract_and_creates_only_one_portfolio(tmp_path: Path) -> None:
-    service, coordinator, root = await _service(tmp_path)
-    result = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "plan-portfolio"},
-    )
-    task_id = result["task_ids"][0]
-    ledger = await coordinator.task_store.load(root)
-    task = ledger.tasks[task_id]
-    assert task.current_spec.context_refs[0].endswith("candidate-portfolio")
-    assert task.current_spec.context_refs[1].startswith("script-build://task-contracts/sha256/")
-    assert task.current_spec.context_refs[2] == "script-build://inputs/11"
-    frozen = await service.contract_for_task(root, task)
-    assert frozen.contract.scope_ref == "script-build://scopes/full"
-
-    with pytest.raises(TaskContractError, match="one Portfolio"):
-        await service.plan_script_tasks(
-            contract_payloads=[
-                _planner_input(_contract("candidate-portfolio", execution_ready=False))
-            ],
-            parent_task_id=None,
-            context={"root_trace_id": root, "tool_call_id": "plan-portfolio-2"},
-        )
-
-
-@pytest.mark.asyncio
-async def test_protected_phase_rejects_cross_phase_root_contracts(tmp_path: Path) -> None:
-    service, coordinator, root = await _service(tmp_path)
-    before = await coordinator.task_store.load(root)
-    existing_task_ids = set(before.tasks)
     with pytest.raises(TaskContractError, match="PHASE_POLICY_VIOLATION"):
-        await service.plan_script_tasks(
-            contract_payloads=[
-                _planner_input(_contract("candidate-portfolio", execution_ready=False))
-            ],
-            parent_task_id=None,
-            context={"root_trace_id": root, "phase": 1},
-        )
-    ledger = await coordinator.task_store.load(root)
-    assert set(ledger.tasks) == existing_task_ids
-
-
-@pytest.mark.asyncio
-async def test_phase_two_root_violation_describes_attempted_and_required_kind(
-    tmp_path: Path,
-) -> None:
-    service, _, root = await _service(tmp_path)
-    with pytest.raises(TaskContractError) as caught:
-        await service.plan_script_tasks(
-            contract_payloads=[_planner_input(_contract("structure"))],
-            parent_task_id=None,
-            context={"root_trace_id": root, "phase": 2},
+        guard.dispatch(
+            context={"phase": 1},
+            kind=ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
         )
 
-    error = caught.value
-    assert "must first plan exactly one candidate-portfolio" in error.summary
-    assert error.details["attempted_task_kinds"] == ["structure"]
-    assert error.details["allowed_task_kinds"] == ["candidate-portfolio"]
 
-
-@pytest.mark.asyncio
-async def test_invalid_contract_batch_creates_zero_tasks(tmp_path: Path) -> None:
-    service, coordinator, root = await _service(tmp_path)
-    before = await coordinator.task_store.load(root)
-    existing_task_ids = set(before.tasks)
-    invalid = _planner_input(_contract("paragraph"))
-    invalid["output_schema"] = "structured-script/v1"
-    with pytest.raises(TaskContractError, match="Host-owned fields"):
-        await service.plan_script_tasks(
-            contract_payloads=[
-                _planner_input(_contract("candidate-portfolio")),
-                invalid,
-            ],
-            parent_task_id=None,
-            context={"root_trace_id": root},
-        )
-    ledger = await coordinator.task_store.load(root)
-    assert set(ledger.tasks) == existing_task_ids
-
-
-@pytest.mark.asyncio
-async def test_planning_rejects_unresolved_supersession_before_freeze(tmp_path: Path) -> None:
-    service, coordinator, root = await _service(tmp_path)
-    portfolio = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "portfolio"},
-    )
-    compose = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("compose", execution_ready=False))],
-        parent_task_id=portfolio["task_ids"][0],
-        context={"root_trace_id": root, "tool_call_id": "compose"},
-    )
-    replacement = _contract("paragraph")
-    replacement["supersedes_decision_ids"] = ["missing-accept"]
-    before = await coordinator.task_store.load(root)
-    existing_task_ids = set(before.tasks)
-
-    with pytest.raises(TaskContractError, match="not an ACCEPT decision"):
-        await service.plan_script_tasks(
-            contract_payloads=[_planner_input(replacement)],
-            parent_task_id=compose["task_ids"][0],
-            context={"root_trace_id": root, "tool_call_id": "invalid-replacement"},
-        )
-
-    ledger = await coordinator.task_store.load(root)
-    assert set(ledger.tasks) == existing_task_ids
-
-
-@pytest.mark.asyncio
-async def test_open_compose_container_cannot_dispatch(tmp_path: Path) -> None:
-    service, coordinator, root = await _service(tmp_path)
-    portfolio = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "p"},
-    )
-    compose = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("compose", execution_ready=False))],
-        parent_task_id=portfolio["task_ids"][0],
-        context={"root_trace_id": root, "tool_call_id": "c"},
-    )
-    before = await coordinator.task_store.load(root)
-    operation_ids = set(before.operations)
-    with pytest.raises(TaskContractError, match="first accept child candidates"):
-        await service.dispatch_script_tasks(
-            task_ids=compose["task_ids"], context={"root_trace_id": root}
-        )
-    ledger = await coordinator.task_store.load(root)
-    assert set(ledger.operations) == operation_ids
-
-
-@pytest.mark.asyncio
-async def test_compose_placeholder_split_replacement_then_revised_contract_passes(
-    tmp_path: Path,
-) -> None:
-    service, coordinator, root = await _service(tmp_path)
-    coordinator.set_executor(_PlaceholderThenReplacementExecutor(coordinator))
-    portfolio = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "portfolio"},
-    )
-    compose = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("compose", execution_ready=False))],
-        parent_task_id=portfolio["task_ids"][0],
-        context={"root_trace_id": root, "tool_call_id": "compose-v1"},
-    )
-    compose_id = compose["task_ids"][0]
-
-    accepted_children: dict[str, str] = {}
-    for kind in ("structure", "paragraph", "element-set"):
-        planned = await service.plan_script_tasks(
-            contract_payloads=[_planner_input(_contract(kind))],
-            parent_task_id=compose_id,
-            context={"root_trace_id": root, "tool_call_id": f"plan:{kind}"},
-        )
-        child_id = cast(str, planned["task_ids"][0])
-        child_cycle = (
-            await service.dispatch_script_tasks(
-                task_ids=(child_id,),
-                context={"root_trace_id": root, "tool_call_id": f"dispatch:{kind}"},
-            )
-        )[0]
-        accepted = await service.decide_script_task(
-            task_id=child_id,
-            action=DecisionAction.ACCEPT.value,
-            reason=f"{kind} passed",
-            validation_id=cast(str, child_cycle["validation_id"]),
-            replacement_contract=None,
-            child_contracts=(),
-            context={"root_trace_id": root, "tool_call_id": f"accept:{kind}"},
-        )
-        accepted_children[kind] = cast(str, accepted["decision_id"])
-    await service.decide_script_task(
-        task_id=compose_id,
-        action=DecisionAction.REVISE.value,
-        reason="freeze the initial complete frontier",
-        validation_id=None,
-        replacement_contract=None,
-        child_contracts=(),
-        selected_decision_ids=tuple(accepted_children.values()),
-        context={"root_trace_id": root, "tool_call_id": "revise-compose-v1"},
-    )
-
-    first_cycle = (
-        await service.dispatch_script_tasks(
-            task_ids=(compose_id,),
-            context={"root_trace_id": root, "tool_call_id": "dispatch-compose-v1"},
-        )
-    )[0]
-    assert first_cycle["error"] is None
-    ledger = await coordinator.task_store.load(root)
-    first_validation = ledger.validations[first_cycle["validation_id"]]
-    assert first_validation.verdict is ValidationVerdict.FAILED
-    assert first_validation.unverified_claims == ["artifact.paragraphs[0].description"]
-    assert first_validation.risks == ["REALIZATION_PLACEHOLDER"]
-
-    split = await service.decide_script_task(
-        task_id=compose_id,
-        action=DecisionAction.SPLIT.value,
-        reason="replace only the unrealized opening scope",
-        validation_id=first_validation.validation_id,
-        replacement_contract=None,
-        child_contracts=(
-            _planner_input(
-                _contract(
-                    "paragraph",
-                    scope="script-build://scopes/full/replacement",
-                )
-            ),
-        ),
-        context={"root_trace_id": root, "tool_call_id": "split-placeholder"},
-    )
-    replacement_id = split["payload"]["child_task_ids"][0]
-    replacement_cycle = (
-        await service.dispatch_script_tasks(
-            task_ids=(replacement_id,),
-            context={"root_trace_id": root, "tool_call_id": "dispatch-replacement"},
-        )
-    )[0]
-    replacement_accept = await service.decide_script_task(
-        task_id=replacement_id,
-        action=DecisionAction.ACCEPT.value,
-        reason="the local replacement passed independent validation",
-        validation_id=replacement_cycle["validation_id"],
-        replacement_contract=None,
-        child_contracts=(),
-        context={"root_trace_id": root, "tool_call_id": "accept-replacement"},
-    )
-
-    ledger = await coordinator.task_store.load(root)
-    assert ledger.tasks[compose_id].status is TaskStatus.NEEDS_REPLAN
-    replacement_decision_id = replacement_accept["decision_id"]
-    await service.decide_script_task(
-        task_id=compose_id,
-        action=DecisionAction.REVISE.value,
-        reason="pin the accepted replacement and retry the complete render",
-        validation_id=None,
-        replacement_contract=None,
-        child_contracts=(),
-        selected_decision_ids=(
-            accepted_children["structure"],
-            replacement_decision_id,
-            accepted_children["element-set"],
-        ),
-        context={"root_trace_id": root, "tool_call_id": "revise-compose-v2"},
-    )
-    second_cycle = (
-        await service.dispatch_script_tasks(
-            task_ids=(compose_id,),
-            context={"root_trace_id": root, "tool_call_id": "dispatch-compose-v2"},
-        )
-    )[0]
-    compose_accept = await service.decide_script_task(
-        task_id=compose_id,
-        action=DecisionAction.ACCEPT.value,
-        reason="the revised full render passed independent validation",
-        validation_id=second_cycle["validation_id"],
-        replacement_contract=None,
-        child_contracts=(),
-        context={"root_trace_id": root, "tool_call_id": "accept-compose-v2"},
-    )
-
-    reloaded = await FileSystemTaskStore(str(tmp_path / "ledger")).load(root)
-    compose_task = reloaded.tasks[compose_id]
-    assert compose_task.status is TaskStatus.COMPLETED
-    assert compose_task.current_spec_version == 3
-    assert len(compose_task.attempt_ids) == 2
-    assert [
-        reloaded.validations[validation_id].verdict for validation_id in compose_task.validation_ids
-    ] == [ValidationVerdict.FAILED, ValidationVerdict.PASSED]
-    assert [reloaded.decisions[item].action for item in compose_task.decision_ids] == [
-        DecisionAction.REVISE,
-        DecisionAction.SPLIT,
-        DecisionAction.REVISE,
-        DecisionAction.ACCEPT,
-    ]
-    assert compose_accept["status"] == TaskStatus.COMPLETED.value
-    assert reloaded.tasks[replacement_id].status is TaskStatus.COMPLETED
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
-    "creation_order",
-    [
-        ("element-set", "paragraph", "structure"),
-        ("paragraph", "element-set", "structure"),
-        ("structure", "paragraph", "element-set"),
-    ],
-)
-async def test_exploration_order_and_completion_order_do_not_choose_compose_order(
-    tmp_path: Path,
-    creation_order: tuple[str, str, str],
-) -> None:
-    service, coordinator, root = await _service(tmp_path)
-    coordinator.set_executor(_PlaceholderThenReplacementExecutor(coordinator))
-    portfolio = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "portfolio"},
-    )
-    compose = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("compose", execution_ready=False))],
-        parent_task_id=portfolio["task_ids"][0],
-        context={"root_trace_id": root, "tool_call_id": "compose"},
-    )
-    compose_id = compose["task_ids"][0]
-    task_by_kind: dict[str, str] = {}
-    for kind in creation_order:
-        planned = await service.plan_script_tasks(
-            contract_payloads=[_planner_input(_contract(kind))],
-            parent_task_id=compose_id,
-            context={"root_trace_id": root, "tool_call_id": f"plan:{kind}"},
-        )
-        task_by_kind[kind] = planned["task_ids"][0]
-
-    decision_by_kind: dict[str, str] = {}
-    completion_order = tuple(reversed(creation_order))
-    for kind in completion_order:
-        task_id = task_by_kind[kind]
-        cycle = (
-            await service.dispatch_script_tasks(
-                task_ids=(task_id,),
-                context={"root_trace_id": root, "tool_call_id": f"dispatch:{kind}"},
-            )
-        )[0]
-        accepted = await service.decide_script_task(
-            task_id=task_id,
-            action=DecisionAction.ACCEPT.value,
-            reason=f"the bounded {kind} increment passed validation",
-            validation_id=cycle["validation_id"],
-            replacement_contract=None,
-            child_contracts=(),
-            context={"root_trace_id": root, "tool_call_id": f"accept:{kind}"},
-        )
-        decision_id = accepted["decision_id"]
-        decision_by_kind[kind] = decision_id
-
-    ledger = await coordinator.task_store.load(root)
-    assert ledger.tasks[compose_id].status is TaskStatus.NEEDS_REPLAN
-    adoption_kinds = ("paragraph", "structure", "element-set")
-    selected = tuple(decision_by_kind[kind] for kind in adoption_kinds)
-    await service.decide_script_task(
-        task_id=compose_id,
-        action=DecisionAction.REVISE.value,
-        reason="formal adoption is based on scope and intent, not timing",
-        validation_id=None,
-        replacement_contract=None,
-        child_contracts=(),
-        selected_decision_ids=selected,
-        context={"root_trace_id": root, "tool_call_id": "close-compose"},
-    )
-
-    reloaded = await FileSystemTaskStore(str(tmp_path / "ledger")).load(root)
-    frozen = await service.contract_for_task(root, reloaded.tasks[compose_id])
-    assert frozen.contract.compose_order == selected
-    assert [
-        next(kind for kind, task_id in task_by_kind.items() if task_id == child_id)
-        for child_id in reloaded.tasks[compose_id].child_task_ids
-    ] == list(creation_order)
-    assert completion_order == tuple(reversed(creation_order))
-
-
-@pytest.mark.asyncio
-async def test_phase_two_retrieval_counts_toward_task_and_depth_limits(tmp_path: Path) -> None:
-    service, _, root = await _service(tmp_path)
-    service.limits = PhaseTwoLimits(max_tasks=2)
-    portfolio = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root, "tool_call_id": "portfolio"},
-    )
-    compose = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("compose", execution_ready=False))],
-        parent_task_id=portfolio["task_ids"][0],
-        context={"root_trace_id": root, "tool_call_id": "compose"},
+def test_phase_boundary_reason_is_host_owned() -> None:
+    guard = PhasePolicyGuard()
+    guard.boundary(
+        context={"phase": 1},
+        reason=PHASE_ONE_CAPABILITY_BOUNDARY,
     )
-    with pytest.raises(TaskContractError, match="Task budget"):
-        await service.plan_script_tasks(
-            contract_payloads=[_planner_input(_contract("decode-retrieval"))],
-            parent_task_id=compose["task_ids"][0],
-            context={"root_trace_id": root, "tool_call_id": "retrieval"},
-        )
-
-    service.limits = PhaseTwoLimits(max_depth=1)
-    with pytest.raises(TaskContractError, match="depth"):
-        await service.plan_script_tasks(
-            contract_payloads=[
-                _planner_input(_contract("structure", scope="script-build://scopes/full/local"))
-            ],
-            parent_task_id=compose["task_ids"][0],
-            context={"root_trace_id": root, "tool_call_id": "too-deep"},
-        )
+    with pytest.raises(TaskContractError, match="PHASE_POLICY_VIOLATION"):
+        guard.boundary(context={"phase": 1}, reason="model-invented-boundary")
 
 
-@pytest.mark.asyncio
-async def test_task_contract_cannot_be_silently_changed_in_task_spec(tmp_path: Path) -> None:
-    service, coordinator, root = await _service(tmp_path)
-    result = await service.plan_script_tasks(
-        contract_payloads=[_planner_input(_contract("candidate-portfolio", execution_ready=False))],
-        parent_task_id=None,
-        context={"root_trace_id": root},
+def test_legacy_catalog_is_readable_but_physical_retrieval_is_not_v2_executable() -> None:
+    assert (
+        LegacyTaskCapabilityCatalog()
+        .get(ScriptTaskKind.KNOWLEDGE_RETRIEVAL)
+        .worker_preset
+        == "script_knowledge_retrieval_worker"
     )
-    ledger = await coordinator.task_store.load(root)
-    task = ledger.tasks[result["task_ids"][0]]
-    task.specs[-1] = replace(task.specs[-1], objective="tampered")
-    await coordinator.task_store.commit(ledger, expected_revision=ledger.revision)
-    with pytest.raises(TaskContractError, match="DIGEST_MISMATCH"):
-        await service.contract_for_task(root, task)
+    with pytest.raises(TaskContractError, match="TASK_CAPABILITY_MISMATCH"):
+        TaskCapabilityCatalog().get(ScriptTaskKind.KNOWLEDGE_RETRIEVAL)

+ 12 - 45
script_build_host/tests/test_prompt_quality.py

@@ -6,13 +6,10 @@ from script_build_host.agents.presets import register_script_presets
 from script_build_host.agents.prompts import (
     COMPARISON_WORKER_PROMPT,
     COMPOSE_WORKER_PROMPT,
-    DECODE_RETRIEVAL_WORKER_PROMPT,
     DIRECTION_WORKER_PROMPT,
     ELEMENT_SET_WORKER_PROMPT,
-    EXTERNAL_RETRIEVAL_WORKER_PROMPT,
-    KNOWLEDGE_RETRIEVAL_WORKER_PROMPT,
+    EVIDENCE_RETRIEVAL_WORKER_PROMPT,
     PARAGRAPH_WORKER_PROMPT,
-    PATTERN_RETRIEVAL_WORKER_PROMPT,
     PLANNER_PROMPT,
     ROOT_VALIDATOR_PROMPT,
     SCRIPT_CANDIDATE_VALIDATOR_PROMPT,
@@ -21,54 +18,24 @@ from script_build_host.agents.prompts import (
 )
 
 
-def test_retrieval_roles_freeze_distinct_source_specific_prompts() -> None:
-    retrieval_presets = {
-        "script_pattern_retrieval_worker": (
-            "pattern_retrieval_worker.md",
-            PATTERN_RETRIEVAL_WORKER_PROMPT,
-            "query_pattern_qa",
-        ),
-        "script_decode_retrieval_worker": (
-            "decode_retrieval_worker.md",
-            DECODE_RETRIEVAL_WORKER_PROMPT,
-            "search_script_decode_case",
-        ),
-        "script_external_retrieval_worker": (
-            "external_retrieval_worker.md",
-            EXTERNAL_RETRIEVAL_WORKER_PROMPT,
-            "external_search_case",
-        ),
-        "script_knowledge_retrieval_worker": (
-            "knowledge_retrieval_worker.md",
-            KNOWLEDGE_RETRIEVAL_WORKER_PROMPT,
-            "search_knowledge",
-        ),
-    }
+def test_logical_retrieval_prompt_keeps_provider_choice_host_owned() -> None:
     manifest = {str(item["preset"]): item for item in script_build_prompt_manifest()}
+    frozen = manifest["script_evidence_retrieval_worker"]
+    assert frozen["source_id"] == "evidence_retrieval_worker.md"
+    assert frozen["content"] == EVIDENCE_RETRIEVAL_WORKER_PROMPT
+    assert "RetrievalPipeline" in EVIDENCE_RETRIEVAL_WORKER_PROMPT
+    assert "不由模型选择" in EVIDENCE_RETRIEVAL_WORKER_PROMPT
 
-    assert len({content for _, content, _ in retrieval_presets.values()}) == 4
-    for preset, (filename, content, tool_name) in retrieval_presets.items():
-        assert manifest[preset]["source_id"] == filename
-        assert manifest[preset]["content"] == content
-        assert tool_name in content
-        assert "且仅再调用一次" in content
-
-
-def test_registered_retrieval_presets_use_their_own_prompt() -> None:
     register_script_presets()
-    expected = {
-        "script_pattern_retrieval_worker": PATTERN_RETRIEVAL_WORKER_PROMPT,
-        "script_decode_retrieval_worker": DECODE_RETRIEVAL_WORKER_PROMPT,
-        "script_external_retrieval_worker": EXTERNAL_RETRIEVAL_WORKER_PROMPT,
-        "script_knowledge_retrieval_worker": KNOWLEDGE_RETRIEVAL_WORKER_PROMPT,
-    }
-    for preset, prompt in expected.items():
-        assert get_preset(preset).system_prompt == prompt
+    assert (
+        get_preset("script_evidence_retrieval_worker").system_prompt
+        == EVIDENCE_RETRIEVAL_WORKER_PROMPT
+    )
 
 
 def test_creative_prompts_preserve_quality_and_convergence_contracts() -> None:
     assert "前期可探索不同的真实路径" in PLANNER_PROMPT
-    assert "一个最主要的缺陷簇" in PLANNER_PROMPT
+    assert "只处理最主要的缺陷簇" in PLANNER_PROMPT
     assert "受众可感知结果" in DIRECTION_WORKER_PROMPT
     assert "不预写 Phase 2" in DIRECTION_WORKER_PROMPT
     assert "段落方案" in DIRECTION_WORKER_PROMPT

+ 17 - 5
script_build_host/tests/test_task_capabilities.py

@@ -5,20 +5,29 @@ from agent.core.presets import AGENT_PRESETS
 from script_build_host.agents.presets import register_script_presets
 from script_build_host.domain.phase_protocol import allowed_child_kinds
 from script_build_host.domain.task_capabilities import task_capabilities, task_capability
-from script_build_host.domain.task_contracts import ScriptTaskKind, output_schema_for
+from script_build_host.domain.task_contracts import ScriptTaskKind
 
 
 def test_capability_catalog_closes_worker_tool_schema_and_topology_contracts() -> None:
     register_script_presets()
     capabilities = task_capabilities()
-    assert {item.task_kind for item in capabilities} == set(ScriptTaskKind)
-
+    assert {item.task_kind for item in capabilities} == {
+        kind
+        for kind in ScriptTaskKind
+        if kind
+        not in {
+            ScriptTaskKind.PATTERN_RETRIEVAL,
+            ScriptTaskKind.DECODE_RETRIEVAL,
+            ScriptTaskKind.EXTERNAL_RETRIEVAL,
+            ScriptTaskKind.KNOWLEDGE_RETRIEVAL,
+        }
+    }
     for capability in capabilities:
         preset = AGENT_PRESETS[capability.worker_preset]
         assert set(preset.allowed_tools) == set(capability.allowed_tools)
-        assert output_schema_for(capability.task_kind) in capability.output_schemas
+        assert capability.output_schema() in capability.output_schemas
         assert allowed_child_kinds(
-            phase=2,
+            phase=capability.phase,
             parent_kind=capability.task_kind,
         ) == capability.child_kinds
 
@@ -34,6 +43,9 @@ def test_phase_root_topology_is_machine_readable_and_fail_closed() -> None:
         {ScriptTaskKind.ROOT_DELIVERY}
     )
     assert not allowed_child_kinds(phase=99, parent_kind=None)
+    assert not allowed_child_kinds(
+        phase=1, parent_kind=ScriptTaskKind.PATTERN_RETRIEVAL
+    )
 
 
 def test_catalog_keeps_element_write_ownership_narrow() -> None:

+ 123 - 0
script_build_host/tests/test_task_planning_v2.py

@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+from agent.orchestration import TaskLedger, TaskRecord, TaskSpec
+from agent.orchestration.models import AcceptanceCriterion
+
+from script_build_host.application.task_planning_v2 import TaskPlanningServiceV2
+from script_build_host.tools.registry import _planner_task_input_schema
+
+
+class Store:
+    def __init__(self, ledger: TaskLedger) -> None:
+        self.ledger = ledger
+
+    async def load(self, root_trace_id: str) -> TaskLedger:
+        assert root_trace_id == self.ledger.root_trace_id
+        return self.ledger
+
+
+class Coordinator:
+    def __init__(self, ledger: TaskLedger) -> None:
+        self.task_store = Store(ledger)
+        self.call = None
+
+    async def replay_command(self, *_args, **_kwargs):
+        return None
+
+    async def create_task_graph(self, root_trace_id: str, drafts, **kwargs):
+        self.call = (root_trace_id, drafts, kwargs)
+        for document in kwargs["context_documents"]:
+            self.task_store.ledger.context_documents[document.uri] = document
+        for draft in drafts:
+            task_id = draft["_task_id"]
+            self.task_store.ledger.tasks[task_id] = TaskRecord(
+                task_id,
+                draft["_parent_task_id"],
+                "1.1",
+                [
+                    TaskSpec(
+                        1,
+                        draft["objective"],
+                        tuple(
+                            AcceptanceCriterion(
+                                item["criterion_id"],
+                                item["description"],
+                                item["hard"],
+                            )
+                            for item in draft["acceptance_criteria"]
+                        ),
+                        tuple(draft["context_refs"]),
+                    )
+                ],
+            )
+        return {"tasks": [{"task_id": drafts[0]["_task_id"]}]}
+
+    async def get_tasks(self, _root_trace_id: str, task_ids):
+        return {"tasks": [{"task_id": item} for item in task_ids]}
+
+
+@pytest.mark.asyncio
+async def test_v2_planning_passes_contract_documents_in_same_task_mutation() -> None:
+    root = TaskRecord(
+        "root-task",
+        None,
+        "1",
+        [
+            TaskSpec(
+                1,
+                "mission",
+                (AcceptanceCriterion("done", "done"),),
+            )
+        ],
+    )
+    ledger = TaskLedger("root", "mission", "root-task", tasks={"root-task": root})
+    coordinator = Coordinator(ledger)
+    service = TaskPlanningServiceV2(
+        coordinator=coordinator,
+        bindings=SimpleNamespace(
+            get_by_root=lambda _: None,
+        ),
+        artifacts=SimpleNamespace(),
+        input_snapshots=SimpleNamespace(),
+    )
+
+    async def binding(_: str):
+        return SimpleNamespace(
+            script_build_id=1,
+            input_snapshot_id="snapshot",
+            active_direction_artifact_version_id=None,
+        )
+
+    service.bindings.get_by_root = binding
+
+    async def input_snapshot(*_args, **_kwargs):
+        return SimpleNamespace(
+            snapshot_id="snapshot",
+            account={},
+            persona_points=(),
+            section_patterns=({"pattern": "opening"},),
+        )
+
+    service.input_snapshots.get = input_snapshot
+    result = await service.plan(
+        intents=[{"task_kind": "direction", "objective": "choose direction"}],
+        context={"root_trace_id": "root", "tool_call_id": "call-1"},
+    )
+    assert result["task_ids"]
+    assert coordinator.call is not None
+    _, drafts, kwargs = coordinator.call
+    documents = kwargs["context_documents"]
+    assert len(documents) == len(drafts) == 1
+    assert documents[0].uri in drafts[0]["context_refs"]
+    assert documents[0].payload["schema_version"] == "script-task-contract/v2"
+
+
+def test_planner_schema_exposes_business_intent_only() -> None:
+    properties = _planner_task_input_schema()["properties"]
+    assert "decision_ids" in properties
+    assert "scope_selector" not in properties
+    assert "criteria" not in properties
+    assert "parent_task_id" not in properties

+ 4 - 3
script_build_host/tests/test_task_policy.py

@@ -3,6 +3,7 @@ from __future__ import annotations
 import pytest
 
 from script_build_host.domain.artifacts import DirectionConstraint
+from script_build_host.domain.task_capabilities import LegacyTaskCapabilityCatalog
 from script_build_host.domain.task_contracts import (
     PlannerCriterionInput,
     ScriptTaskKind,
@@ -40,8 +41,8 @@ def test_task_policy_injects_protected_kind_and_direction_constraint_criteria()
     assert criteria[2].hard is False
 
 
-def test_retrieval_criteria_are_typed_and_host_rendered() -> None:
-    criteria = TaskPolicyCatalog().criteria(
+def test_legacy_retrieval_criteria_remain_readable_and_host_rendered() -> None:
+    criteria = TaskPolicyCatalog(LegacyTaskCapabilityCatalog()).criteria(
         task_id="external",
         task_kind=ScriptTaskKind.EXTERNAL_RETRIEVAL,
         business=(
@@ -58,7 +59,7 @@ def test_retrieval_criteria_are_typed_and_host_rendered() -> None:
 
 def test_retrieval_rejects_free_text_synthesis_contract_before_dispatch() -> None:
     with pytest.raises(TaskContractError, match="TASK_CAPABILITY_MISMATCH"):
-        TaskPolicyCatalog().criteria(
+        TaskPolicyCatalog(LegacyTaskCapabilityCatalog()).criteria(
             task_id="external",
             task_kind=ScriptTaskKind.EXTERNAL_RETRIEVAL,
             business=(