pdl-taskmaster 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. pdl_taskmaster/__init__.py +6 -0
  2. pdl_taskmaster/contracts/AUTHORITY_MAP.json +19 -0
  3. pdl_taskmaster/contracts/CALIBRATION.json +7 -0
  4. pdl_taskmaster/contracts/CONTRACT_MANIFEST.json +231 -0
  5. pdl_taskmaster/contracts/EXECUTION_CONTRACT.json +615 -0
  6. pdl_taskmaster/contracts/IMPLEMENTATION_BOUNDARY.json +32 -0
  7. pdl_taskmaster/contracts/VERIFICATION_CONTRACT.json +379 -0
  8. pdl_taskmaster/contracts/standards/ARTIFACT_STANDARD.md +7 -0
  9. pdl_taskmaster/contracts/standards/AUTHORITY_STANDARD.md +17 -0
  10. pdl_taskmaster/contracts/standards/CONFORMANCE_STANDARD.md +15 -0
  11. pdl_taskmaster/contracts/standards/CONTEXT_STANDARD.md +15 -0
  12. pdl_taskmaster/contracts/standards/EXECUTION_STANDARD.md +14 -0
  13. pdl_taskmaster/contracts/standards/HANDOFF_STANDARD.md +21 -0
  14. pdl_taskmaster/contracts/standards/INSTALLATION_STANDARD.md +17 -0
  15. pdl_taskmaster/contracts/standards/PDL_STANDARD.md +19 -0
  16. pdl_taskmaster/contracts/standards/PROMPT_STANDARD.md +13 -0
  17. pdl_taskmaster/contracts/standards/PROTOCOL_STANDARD.md +17 -0
  18. pdl_taskmaster/contracts/standards/RESPONSE_PLAN_STANDARD.md +24 -0
  19. pdl_taskmaster/contracts/standards/RESULT_STANDARD.md +35 -0
  20. pdl_taskmaster/contracts/standards/REVIEW_STANDARD.md +31 -0
  21. pdl_taskmaster/contracts/standards/SEMANTIC_INPUT_STANDARD.md +15 -0
  22. pdl_taskmaster/contracts/standards/TASK_SEMANTICS_STANDARD.md +9 -0
  23. pdl_taskmaster/controller/__init__.py +0 -0
  24. pdl_taskmaster/controller/mechanical_controller.py +542 -0
  25. pdl_taskmaster/controller/schemas/activation_decision.schema.json +34 -0
  26. pdl_taskmaster/controller/schemas/artifact_body.schema.json +14 -0
  27. pdl_taskmaster/controller/schemas/artifact_review_facts.schema.json +91 -0
  28. pdl_taskmaster/controller/schemas/bootstrap_analysis.schema.json +60 -0
  29. pdl_taskmaster/controller/schemas/execution_draft.schema.json +47 -0
  30. pdl_taskmaster/controller/schemas/execution_input_decision.schema.json +43 -0
  31. pdl_taskmaster/controller/schemas/execution_outcome.schema.json +201 -0
  32. pdl_taskmaster/controller/schemas/plan_artifact.schema.json +14 -0
  33. pdl_taskmaster/controller/schemas/prompt_artifact.schema.json +63 -0
  34. pdl_taskmaster/controller/schemas/protocol_discussion.schema.json +8 -0
  35. pdl_taskmaster/controller/schemas/result_ir_repair.schema.json +140 -0
  36. pdl_taskmaster/eval/__init__.py +1 -0
  37. pdl_taskmaster/eval/build_adversarial_battery.py +351 -0
  38. pdl_taskmaster/eval/compare_eval_runs.py +251 -0
  39. pdl_taskmaster/eval/control_api_call.py +428 -0
  40. pdl_taskmaster/eval/export_rlcd_dataset.py +799 -0
  41. pdl_taskmaster/eval/fidelity_scan.py +268 -0
  42. pdl_taskmaster/eval/gen_fidelity_battery.py +457 -0
  43. pdl_taskmaster/eval/leak_scan.py +260 -0
  44. pdl_taskmaster/eval/rerecord_fixtures.py +139 -0
  45. pdl_taskmaster/eval/run_canary_shape_probe.py +229 -0
  46. pdl_taskmaster/eval/run_dual_gate_probe.py +404 -0
  47. pdl_taskmaster/eval/run_qualified_batch.py +841 -0
  48. pdl_taskmaster/eval/summarize_run.py +194 -0
  49. pdl_taskmaster/host/__init__.py +1 -0
  50. pdl_taskmaster/host/app.py +164 -0
  51. pdl_taskmaster/host/cli.py +100 -0
  52. pdl_taskmaster/host/repl.py +866 -0
  53. pdl_taskmaster/observation/__init__.py +1 -0
  54. pdl_taskmaster/observation/cli.py +47 -0
  55. pdl_taskmaster/observation/observed_session.py +219 -0
  56. pdl_taskmaster/observation/parsers.py +66 -0
  57. pdl_taskmaster/observation/records.py +58 -0
  58. pdl_taskmaster/observation/session.py +3 -0
  59. pdl_taskmaster/observation/sink.py +3 -0
  60. pdl_taskmaster/observation/sinks.py +20 -0
  61. pdl_taskmaster/providers/README.md +66 -0
  62. pdl_taskmaster/providers/__init__.py +1 -0
  63. pdl_taskmaster/providers/api_worker.py +411 -0
  64. pdl_taskmaster/providers/base.py +26 -0
  65. pdl_taskmaster/providers/codex_worker.py +328 -0
  66. pdl_taskmaster/providers/fixtures.py +157 -0
  67. pdl_taskmaster/providers/live_stub.py +66 -0
  68. pdl_taskmaster/providers/recorded.py +120 -0
  69. pdl_taskmaster/py.typed +1 -0
  70. pdl_taskmaster/runtime/__init__.py +0 -0
  71. pdl_taskmaster/runtime/context_compiler.py +151 -0
  72. pdl_taskmaster/runtime/model_classification.py +290 -0
  73. pdl_taskmaster/runtime/normative_store.py +157 -0
  74. pdl_taskmaster/runtime/operation_bridge.py +336 -0
  75. pdl_taskmaster/runtime/presentation.py +18 -0
  76. pdl_taskmaster/runtime/quarantine.py +91 -0
  77. pdl_taskmaster/runtime/result_ir.py +401 -0
  78. pdl_taskmaster/runtime/semantic_observer.py +40 -0
  79. pdl_taskmaster/runtime/session_engine.py +1168 -0
  80. pdl_taskmaster/runtime/standard_registry.py +55 -0
  81. pdl_taskmaster/runtime/wire_payloads.py +462 -0
  82. pdl_taskmaster/runtime/worker-bootstrap.txt +4 -0
  83. pdl_taskmaster/runtime/workspace.py +602 -0
  84. pdl_taskmaster/tracking/__init__.py +1 -0
  85. pdl_taskmaster/tracking/log_live_session.py +98 -0
  86. pdl_taskmaster/tracking/mlflow_sink.py +58 -0
  87. pdl_taskmaster/verify/verify_repl_baseline.py +299 -0
  88. pdl_taskmaster-2.4.0.dist-info/METADATA +155 -0
  89. pdl_taskmaster-2.4.0.dist-info/RECORD +92 -0
  90. pdl_taskmaster-2.4.0.dist-info/WHEEL +4 -0
  91. pdl_taskmaster-2.4.0.dist-info/entry_points.txt +2 -0
  92. pdl_taskmaster-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1168 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Optional
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ import hashlib
11
+
12
+ from pdl_taskmaster.controller.mechanical_controller import (
13
+ AtomicJsonStore,
14
+ ControllerError,
15
+ Intent,
16
+ MechanicalController,
17
+ MemoryAtomicJsonStore,
18
+ NextAction,
19
+ ProtocolState,
20
+ ReviewDecision,
21
+ Stage,
22
+ Transition,
23
+ )
24
+ from pdl_taskmaster.runtime.operation_bridge import ActivationRoute, ModelRequest, OperationBridge, WireError
25
+ from pdl_taskmaster.runtime.quarantine import compile_bootstrap_output
26
+
27
+
28
+ def _norm(text: str) -> str:
29
+ """Whitespace-normalized form for verbatim entity matching: collapses all
30
+ whitespace runs to single spaces so formatting variance does not cause
31
+ false entity-miss retries (the 4B check is about content, not layout)."""
32
+ return " ".join((text or "").split())
33
+ from pdl_taskmaster.runtime.workspace import MemoryWorkspaceRun, WorkspaceError, WorkspaceRun
34
+ from pdl_taskmaster.runtime import presentation
35
+
36
+
37
+ ModelCall = Callable[[ModelRequest], str]
38
+
39
+
40
+ _EXPLICIT_INVOCATION = re.compile(
41
+ r"^\s*(?:(?:please\s+)?(?:(?:could|can|would)\s+you\s+)?(?:use|apply|invoke|run)\s+)?"
42
+ r"(?:\$confirm-with-pseudocode|\[\$confirm-with-pseudocode\]\([^)]+\))"
43
+ r"\s*(?:(?:to)\b|[.:,;-])?\s*(?P<body>.*)$",
44
+ re.IGNORECASE | re.DOTALL,
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class InvocationObservation:
50
+ explicit: bool
51
+ substantive_request: str
52
+
53
+
54
+ def observe_invocation(user_message: str) -> InvocationObservation:
55
+ """Recognize host-visible leading skill invocation syntax.
56
+
57
+ Only the leading control wrapper is removed. Mentions inside the substantive
58
+ request remain data, including tasks whose subject is this protocol.
59
+ """
60
+ match = _EXPLICIT_INVOCATION.match(user_message)
61
+ if not match:
62
+ return InvocationObservation(False, user_message.strip())
63
+ return InvocationObservation(True, match.group("body").strip())
64
+
65
+
66
+ @dataclass
67
+ class CallTrace:
68
+ operation: str
69
+ projection_manifest: dict[str, Any]
70
+ model_text: str
71
+ workspace_stage: str
72
+ workspace_invocation_id: str
73
+
74
+
75
+ @dataclass
76
+ class EngineResponse:
77
+ text: str | None
78
+ traces: list[CallTrace] = field(default_factory=list)
79
+ bypass: bool = False
80
+ closed: bool = False
81
+
82
+
83
+ class SessionEngine:
84
+ """Condition C session orchestrator.
85
+
86
+ Control flow is owned by MechanicalController. Context flow and durable
87
+ stage-to-stage handoff are owned by WorkspaceRun. Semantic worker calls are
88
+ stateless and receive only the operation projection compiled from the
89
+ materialized stage inputs plus applicable Standard clauses.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ repo_root: str | Path,
95
+ model_call: ModelCall,
96
+ *,
97
+ higher_priority_constraints: Any = None,
98
+ available_execution_tools: Any = None,
99
+ workspace_root: str | Path | None = None,
100
+ render_compact: bool = False,
101
+ ):
102
+ self.repo_root = Path(repo_root)
103
+ self.model_call = model_call
104
+ self.higher_priority_constraints = higher_priority_constraints
105
+ self.available_execution_tools = available_execution_tools
106
+ self.bridge = OperationBridge(self.repo_root, render_compact=render_compact)
107
+ self.controller: Optional[MechanicalController] = None
108
+ self.workspace: Optional[WorkspaceRun] = None
109
+ # Protocol v2: semantic-bootstrap containment (structural, non-optional).
110
+ # Raw untrusted content is read by BOOTSTRAP_ANALYSIS only; every compile
111
+ # operation receives the sanitized compiled analysis. Cache is keyed on
112
+ # the raw source so repeated sources bootstrap once per session.
113
+ self._bootstrap_cache: dict[tuple[str, str | None], str] = {}
114
+ self._task_entities_cache: dict[tuple[str, str | None], tuple[str, ...]] = {}
115
+ # S4: confirmed deliverable carried from the prior turn (chaining);
116
+ # None for first turns and legacy single-turn workspaces.
117
+ self._previous_deliverable: str | None = None
118
+ self._active_task_entities: tuple[str, ...] = ()
119
+ if workspace_root is None:
120
+ self.workspace_root = Path(tempfile.mkdtemp(prefix="pdl-c0-workspaces-"))
121
+ else:
122
+ self.workspace_root = Path(workspace_root)
123
+ self.workspace_root.mkdir(parents=True, exist_ok=True)
124
+
125
+ @classmethod
126
+ def restore(
127
+ cls,
128
+ repo_root: str | Path,
129
+ model_call: ModelCall,
130
+ workspace_path: str | Path,
131
+ *,
132
+ higher_priority_constraints: Any = None,
133
+ available_execution_tools: Any = None,
134
+ render_compact: bool = False,
135
+ ) -> "SessionEngine":
136
+ workspace_path = Path(workspace_path)
137
+ engine = cls(
138
+ repo_root,
139
+ model_call,
140
+ higher_priority_constraints=higher_priority_constraints,
141
+ available_execution_tools=available_execution_tools,
142
+ workspace_root=workspace_path.parent,
143
+ render_compact=render_compact,
144
+ )
145
+ workspace = MemoryWorkspaceRun.open(repo_root, workspace_path)
146
+ # Pointer may sit on a turn whose controller never committed (e.g. a
147
+ # turn opened by S4 chaining and then interrupted before any model
148
+ # call). Fall back to the most recent turn with a committed,
149
+ # instance-matching state before declaring the session unrestorable.
150
+ candidates: list[tuple[str | None, Path, str | None]] = []
151
+ if workspace.turn_id is not None:
152
+ ordered_ids = [workspace.turn_id]
153
+ turns_dir = workspace.path / "turns"
154
+ if turns_dir.is_dir():
155
+ ordered_ids.extend(
156
+ d.name for d in sorted(turns_dir.iterdir(), reverse=True)
157
+ if d.is_dir() and re.match(r"^turn_\d+$", d.name) and d.name != workspace.turn_id
158
+ )
159
+ for turn_id in ordered_ids:
160
+ turn_meta = workspace.read_turn_status(turn_id)
161
+ candidates.append((
162
+ turn_id,
163
+ workspace.path / "turns" / turn_id / "state" / "controller-state.json",
164
+ turn_meta.get("protocol_instance_id") or workspace.protocol_instance_id,
165
+ ))
166
+ else:
167
+ if workspace.protocol_instance_id is None:
168
+ raise WorkspaceError("restorable_protocol_state_missing")
169
+ candidates.append((None, workspace.controller_state_path, workspace.protocol_instance_id))
170
+ restore_state_path: Path | None = None
171
+ for turn_id, candidate_path, expected_instance in candidates:
172
+ if not candidate_path.is_file():
173
+ continue
174
+ candidate_state = json.loads(candidate_path.read_text(encoding="utf-8"))
175
+ if candidate_state.get("instance_id") == expected_instance:
176
+ restore_state_path = candidate_path
177
+ if turn_id is not None and turn_id != workspace.turn_id:
178
+ workspace.metadata["turn_id"] = turn_id
179
+ workspace._atomic_write(
180
+ workspace.metadata_path,
181
+ json.dumps(workspace.metadata, ensure_ascii=False, indent=2) + "\n",
182
+ )
183
+ workspace.turn_id = turn_id
184
+ break
185
+ if restore_state_path is None:
186
+ raise WorkspaceError("restorable_protocol_state_missing")
187
+ store = (
188
+ MemoryAtomicJsonStore(restore_state_path)
189
+ if isinstance(workspace, MemoryWorkspaceRun)
190
+ else AtomicJsonStore(restore_state_path)
191
+ )
192
+ state = store.load()
193
+ if state.instance_id != workspace.protocol_instance_id and workspace.turn_id is None:
194
+ raise WorkspaceError("restore_instance_binding")
195
+ engine.workspace = workspace
196
+ engine.controller = MechanicalController(state, store)
197
+ # Cross-epoch deliverable chaining: repopulate the prior confirmed
198
+ # deliverable on restore. The S4 turn-chaining path sets this field
199
+ # only when a new user message opens a turn in the same process; a
200
+ # resumed epoch (e.g. /confirm in a later REPL invocation) must
201
+ # re-chain it so the execution phase still receives the byte-exact
202
+ # prior deliverable in REQUIRED_TASK_INPUTS. The active turn's status
203
+ # is ACTIVE at restore time, so previous_deliverable() correctly
204
+ # returns the last CLOSED_SUCCESS turn before it.
205
+ engine._previous_deliverable = workspace.previous_deliverable()
206
+ workspace.append_event("SESSION_RESTORED", {"instance_id": state.instance_id})
207
+ return engine
208
+
209
+ def _new_workspace(self) -> WorkspaceRun:
210
+ # ADR-0011 / ADR-0008: software-defined in-memory VFS workspace run.
211
+ # Fast, unjournaled writes bypass Windows NTFS fsync latency.
212
+ # Legacy flat workspaces remain supported for direct WorkspaceRun.create callers.
213
+ return MemoryWorkspaceRun.create(self.repo_root, self.workspace_root, turn_id="turn_001")
214
+
215
+ def _bind_new_controller(self, workspace: WorkspaceRun) -> MechanicalController:
216
+ state = ProtocolState.new()
217
+ workspace.bind_protocol(state.instance_id)
218
+ store = (
219
+ MemoryAtomicJsonStore(workspace.controller_state_path)
220
+ if isinstance(workspace, MemoryWorkspaceRun)
221
+ else AtomicJsonStore(workspace.controller_state_path)
222
+ )
223
+ controller = MechanicalController(state, store)
224
+ workspace.publish_approach_sources([])
225
+ return controller
226
+
227
+ def _invoke(self, request: ModelRequest, traces: list[CallTrace]) -> str:
228
+ model_text = self.model_call(request)
229
+ self.workspace.record_model_output(request.workspace_invocation, model_text)
230
+ traces.append(
231
+ CallTrace(
232
+ request.operation,
233
+ request.manifest,
234
+ model_text,
235
+ request.workspace_invocation.stage,
236
+ request.workspace_invocation.invocation_id,
237
+ )
238
+ )
239
+ return model_text
240
+
241
+ def _call(
242
+ self,
243
+ operation: str,
244
+ values: dict[str, Any],
245
+ traces: list[CallTrace],
246
+ parser: Callable[[str], Any] | None = None,
247
+ ) -> Any:
248
+ """Invoke one operation. With a parser, retry ONCE on WireError with an
249
+ operator correction so a sampling glitch (invalid JSON, dropped field)
250
+ costs one extra call instead of fatally failing the session."""
251
+ if self.workspace is None:
252
+ raise WorkspaceError("workspace_not_initialized")
253
+ request = self.bridge.request(
254
+ operation,
255
+ values,
256
+ workspace=self.workspace,
257
+ higher_priority_constraints=self.higher_priority_constraints,
258
+ )
259
+ model_text = self._invoke(request, traces)
260
+ if parser is None:
261
+ return model_text
262
+ try:
263
+ return parser(model_text)
264
+ except WireError as first_error:
265
+ feedback = getattr(first_error, "operator_feedback", None)
266
+ if feedback:
267
+ correction = (
268
+ f"OPERATOR CORRECTION: {feedback}. "
269
+ "Emit exactly one conforming JSON object matching the declared schema."
270
+ )
271
+ else:
272
+ correction = (
273
+ "OPERATOR CORRECTION: the previous response failed host-side validation "
274
+ f"(reason: {first_error}). Emit exactly one JSON object that conforms to the "
275
+ "declared output_schema for this operation, with no prose or code fences around it."
276
+ )
277
+ retry_request = self.bridge.request(
278
+ operation,
279
+ values,
280
+ workspace=self.workspace,
281
+ higher_priority_constraints=self.higher_priority_constraints,
282
+ operator_correction=correction,
283
+ )
284
+ retry_text = self._invoke(retry_request, traces)
285
+ try:
286
+ return parser(retry_text)
287
+ except WireError:
288
+ raise first_error from None
289
+
290
+ def _publish_prompt(self) -> None:
291
+ assert self.controller is not None and self.workspace is not None
292
+ prompt = self.controller.state.current_prompt
293
+ assert prompt is not None
294
+ self.workspace.publish_artifact("prompt", prompt.artifact_id, prompt.body, confirmed=prompt.confirmed)
295
+ self.workspace.publish_approach_sources(list(self.controller.state.approach_sources))
296
+
297
+ def _publish_plan(self) -> None:
298
+ assert self.controller is not None and self.workspace is not None
299
+ plan = self.controller.state.current_plan
300
+ assert plan is not None
301
+ prompt = self.controller.state.current_prompt
302
+ assert prompt is not None
303
+ self.workspace.publish_artifact(
304
+ "plan",
305
+ plan.artifact_id,
306
+ plan.body,
307
+ confirmed=plan.confirmed,
308
+ source_prompt_id=plan.source_prompt_id,
309
+ confirmed_prompt_hash=hashlib.sha256(prompt.body.encode("utf-8")).hexdigest(),
310
+ )
311
+ self.workspace.publish_approach_sources(list(self.controller.state.approach_sources))
312
+
313
+ def _semantic_read(self, raw_text: str, traces: list[CallTrace]) -> str | None:
314
+ """Protocol v2 structural containment: the ONLY operation that sees raw
315
+ untrusted content. Returns the sanitized compiled analysis for compile
316
+ operations, or None when the bootstrap blocks under higher priority.
317
+ """
318
+ cache_key = (raw_text, self._previous_deliverable)
319
+ if cache_key in self._bootstrap_cache:
320
+ return self._bootstrap_cache[cache_key]
321
+ bootstrap_values: dict[str, Any] = {
322
+ "HOST_PROTOCOL_STATE": "SEMANTIC_READ",
323
+ "RAW_UNTRUSTED_CONTENT": raw_text,
324
+ }
325
+ if self._previous_deliverable:
326
+ # S4: the prior turn's confirmed deliverable is host-published,
327
+ # gate-passed content (never raw untrusted input). It is compiled
328
+ # as inactive background context for the new turn.
329
+ bootstrap_values["PREVIOUS_DELIVERABLE"] = self._previous_deliverable
330
+ outcome = self._call(
331
+ "BOOTSTRAP_ANALYSIS",
332
+ bootstrap_values,
333
+ traces,
334
+ parser=self.bridge.parse_bootstrap_analysis,
335
+ )
336
+ if outcome["kind"] == "BLOCKED_BY_HIGHER_PRIORITY":
337
+ self._bootstrap_cache[cache_key] = ""
338
+ return None
339
+ import hashlib
340
+
341
+ compiled, _meta = compile_bootstrap_output(raw_text, outcome["task_summary"])
342
+ # Containment-boundary durability: a lazy semantic read that classifies
343
+ # substantive raw content as instruction-free loses the entire task
344
+ # upstream of every gate. Detect and retry once before compiling.
345
+ summary_lowers = (outcome["task_summary"] or "").lower()
346
+ if len(raw_text) > 500 and (
347
+ "no operative instructions" in summary_lowers
348
+ or "no substantive" in summary_lowers
349
+ or "no requested" in summary_lowers
350
+ or len((outcome["task_summary"] or "").strip()) < 40
351
+ ):
352
+ self.workspace.append_event(
353
+ "BOOTSTRAP_DEGENERATE_READ_RETRY",
354
+ {"raw_chars": len(raw_text), "summary_chars": len(outcome["task_summary"] or "")},
355
+ ) if self.workspace is not None else None
356
+ outcome = self._call(
357
+ "BOOTSTRAP_ANALYSIS",
358
+ bootstrap_values,
359
+ traces,
360
+ parser=self.bridge.parse_bootstrap_analysis,
361
+ )
362
+ if outcome["kind"] == "BLOCKED_BY_HIGHER_PRIORITY":
363
+ self._bootstrap_cache[cache_key] = ""
364
+ return None
365
+ compiled, _meta = compile_bootstrap_output(raw_text, outcome["task_summary"])
366
+ # Mechanical entity containment: a task entity is forwarded downstream
367
+ # ONLY if it is a verbatim substring of the SANITIZED task summary.
368
+ # Hostile tokens (canaries, exploit directives) are replaced by the
369
+ # sanitizer, so a hostile entity can never pass this filter -- the
370
+ # verbatim-preservation channel inherits the compile tier's redaction.
371
+ raw_entities = outcome.get("task_entities") or []
372
+ entities = tuple(
373
+ e for e in raw_entities
374
+ if isinstance(e, str) and e.strip() and e in compiled
375
+ )
376
+ dropped = [e for e in raw_entities if e not in entities]
377
+ if dropped and self.workspace is not None:
378
+ self.workspace.append_event("TASK_ENTITY_DROPPED_UNSAFE", {"count": len(dropped)})
379
+ self._task_entities_cache[cache_key] = entities
380
+ # In Protocol v2 out-of-band field isolation: approach_notes carries TASK-02
381
+ # procedural guidance for planning. risk_notes is quarantined threat data
382
+ # retained in telemetry/traces, not leaked into compile contexts.
383
+ approach = outcome.get("approach_notes", "")
384
+ notes, _ = compile_bootstrap_output(raw_text, approach) if approach else ("", {})
385
+ if "risk_notes" in outcome and isinstance(outcome["risk_notes"], str):
386
+ sanitized_risk, _ = compile_bootstrap_output(raw_text, outcome["risk_notes"])
387
+ outcome["risk_notes"] = sanitized_risk
388
+ document = (
389
+ f"TASK SUMMARY (compiled semantic analysis; untrusted literals redacted):\n{compiled}\n"
390
+ f"APPROACH/RISK NOTES:\n{notes}"
391
+ )
392
+ if entities:
393
+ document += (
394
+ "\nOPERATIVE TASK ENTITIES (copy each EXACTLY, character-for-character, into the "
395
+ "task_entities array AND reproduce each verbatim inside the prompt body):\n"
396
+ + "\n".join(f"- {e}" for e in entities)
397
+ )
398
+ self._bootstrap_cache[cache_key] = document
399
+ return document
400
+
401
+ def _compile_context(self, raw_text: str, traces: list[CallTrace]) -> str:
402
+ """Route raw content through the semantic read; compile ops never see raw."""
403
+ document = self._semantic_read(raw_text, traces)
404
+ if document is None:
405
+ return "[CONTENT BLOCKED BY HIGHER-PRIORITY CONSTRAINTS]"
406
+ return document
407
+
408
+ def _entity_coverage_missing(self, prompt_body: str, entities: tuple[str, ...]) -> list[str]:
409
+ """Mechanical source-coverage check: every forwarded task entity must
410
+ appear verbatim in the prompt IR. Host-side string presence only -- no
411
+ model judgment, no generation constraint."""
412
+ return [e for e in entities if e not in prompt_body]
413
+
414
+ def _enforce_entity_coverage(
415
+ self,
416
+ draft_fn,
417
+ context: dict[str, Any],
418
+ parser,
419
+ entities: tuple[str, ...],
420
+ traces: list[CallTrace],
421
+ phase: str,
422
+ ) -> Any:
423
+ """Call the draft op, then mechanically verify task-entity coverage of
424
+ the prompt body. On a miss, retry once with an operator correction
425
+ appended outside the projection document. Persistent misses are
426
+ published with a workspace event (utility-first: measurable, not
427
+ fatal)."""
428
+ outcome = draft_fn(context, traces, parser=parser)
429
+ if getattr(outcome, "kind", "") == "TASK_BLOCKED_BY_HIGHER_PRIORITY":
430
+ return outcome
431
+ missing = self._entity_coverage_missing(outcome.prompt_body or "", entities)
432
+ if not missing:
433
+ return outcome
434
+ if self.workspace is not None:
435
+ self.workspace.append_event("TASK_ENTITY_COVERAGE_RETRY", {"missing": len(missing)})
436
+ corrected_context = dict(context)
437
+ corrected_context["SUBSTANTIVE_REQUEST"] = (
438
+ context["SUBSTANTIVE_REQUEST"]
439
+ + "\n\nOPERATOR CORRECTION (host-side mechanical check): the following task entities are "
440
+ "missing from the prompt body and MUST appear verbatim, character-for-character: "
441
+ + "; ".join(missing)
442
+ )
443
+ outcome = draft_fn(corrected_context, traces, parser=parser)
444
+ if getattr(outcome, "kind", "") == "TASK_BLOCKED_BY_HIGHER_PRIORITY":
445
+ return outcome
446
+ missing = self._entity_coverage_missing(outcome.prompt_body or "", entities)
447
+ if missing and self.workspace is not None:
448
+ self.workspace.append_event(
449
+ "TASK_ENTITY_COVERAGE_MISSING",
450
+ {"entities": list(missing), "phase": phase},
451
+ )
452
+ return outcome
453
+
454
+ def _draft_initial_prompt(
455
+ self,
456
+ substantive_request: str,
457
+ traces: list[CallTrace],
458
+ *,
459
+ protocol_state: str,
460
+ ) -> EngineResponse:
461
+ assert self.workspace is not None
462
+ # Protocol v2: raw content is read by BOOTSTRAP_ANALYSIS only; the
463
+ # compile op receives the sanitized compiled analysis.
464
+ compiled = self._semantic_read(substantive_request, traces)
465
+ if compiled is None:
466
+ self.workspace.append_event("PROTOCOL_BLOCKED", {"phase": "bootstrap"})
467
+ return EngineResponse(presentation.cancelled(), traces, closed=True)
468
+ entities = self._task_entities_cache.get((substantive_request, self._previous_deliverable), ())
469
+ self._active_task_entities = entities
470
+
471
+ def _draft_call(ctx: dict[str, Any], tr: list[CallTrace], parser) -> Any:
472
+ return self._call("DRAFT_PROMPT", ctx, tr, parser=parser)
473
+
474
+ outcome = self._enforce_entity_coverage(
475
+ _draft_call,
476
+ {
477
+ "HOST_PROTOCOL_STATE": protocol_state,
478
+ "SUBSTANTIVE_REQUEST": compiled,
479
+ },
480
+ self.bridge.parse_prompt_draft,
481
+ entities,
482
+ traces,
483
+ phase="draft_prompt",
484
+ )
485
+ if outcome.kind == "TASK_BLOCKED_BY_HIGHER_PRIORITY":
486
+ self.workspace.append_event(
487
+ "PROTOCOL_BLOCKED",
488
+ {"phase": "prompt_draft", "blocking_basis": outcome.blocking_basis},
489
+ )
490
+ return EngineResponse(outcome.response, traces, closed=True)
491
+ assert outcome.prompt_body is not None
492
+ self.controller = self._bind_new_controller(self.workspace)
493
+ approach_source = substantive_request if outcome.approach_handoff == "CARRY_SOURCE_TO_PLAN" else None
494
+ self.controller.commit_initial_prompt(outcome.prompt_body, approach_source)
495
+ self._publish_prompt()
496
+ return EngineResponse(presentation.prompt_artifact(outcome.prompt_body), traces)
497
+
498
+ def _sync_review_edit(self) -> None:
499
+ assert self.controller is not None and self.workspace is not None
500
+ state = self.controller.state
501
+ if state.stage == Stage.PROMPT_REVIEW and state.current_prompt:
502
+ body = self.workspace.sync_unconfirmed_edit("prompt", state.current_prompt.artifact_id, state.current_prompt.body)
503
+ if body != state.current_prompt.body:
504
+ self.controller.replace_current_unconfirmed_body("prompt", body)
505
+ elif state.stage == Stage.PLAN_REVIEW and state.current_plan:
506
+ self.workspace.validate_confirmed_artifact(
507
+ "prompt", state.current_prompt.artifact_id, state.current_prompt.body # type: ignore[union-attr]
508
+ )
509
+ body = self.workspace.sync_unconfirmed_edit("plan", state.current_plan.artifact_id, state.current_plan.body)
510
+ if body != state.current_plan.body:
511
+ self.controller.replace_current_unconfirmed_body("plan", body)
512
+
513
+ def _activation(self, user_message: str, traces: list[CallTrace]) -> EngineResponse | None:
514
+ # S4 cross-turn chaining (ADR-0008 §4): when the SAME session's prior
515
+ # turn reached a terminal stage and the user issues a new command,
516
+ # continue in the same workspace under the next turn id. Only the prior
517
+ # turn's confirmed deliverable carries forward; drafts, rejected plans,
518
+ # and review dialogue are structurally unreachable in the new turn.
519
+ prior = self.workspace
520
+ if (
521
+ prior is not None
522
+ and prior.turn_id is not None
523
+ and self.controller is not None
524
+ and self.controller.state.stage in {Stage.CLOSED_SUCCESS, Stage.CLOSED_CANCELLED}
525
+ ):
526
+ prior_status = prior.read_turn_status(prior.turn_id).get("status")
527
+ if prior_status == "ACTIVE":
528
+ prior.mark_turn_status(
529
+ "CLOSED_SUCCESS"
530
+ if self.controller.state.stage == Stage.CLOSED_SUCCESS
531
+ else "CLOSED_CANCELLED"
532
+ )
533
+ chained_deliverable = prior.previous_deliverable()
534
+ prior.start_turn(prior.next_turn_id())
535
+ self.workspace = prior
536
+ self._previous_deliverable = chained_deliverable
537
+ self.workspace.append_event(
538
+ "TURN_CHAINED",
539
+ {"turn_id": prior.turn_id, "previous_deliverable": chained_deliverable is not None},
540
+ )
541
+ else:
542
+ self.workspace = self._new_workspace()
543
+ self._previous_deliverable = None
544
+ observation = observe_invocation(user_message)
545
+ if observation.explicit:
546
+ self.workspace.append_event(
547
+ "EXPLICIT_INVOCATION_OBSERVED",
548
+ {"substantive_request_present": bool(observation.substantive_request)},
549
+ )
550
+ return self._draft_initial_prompt(
551
+ observation.substantive_request,
552
+ traces,
553
+ protocol_state="ACTIVE_BY_EXPLICIT_INVOCATION",
554
+ )
555
+ decision = self._call(
556
+ "INTERPRET_ACTIVATION", {"RAW_USER_MESSAGE": user_message}, traces,
557
+ parser=self.bridge.parse_activation,
558
+ )
559
+ if decision.route == ActivationRoute.BLOCKED_BY_HIGHER_PRIORITY:
560
+ return EngineResponse(decision.response, traces, closed=True)
561
+ if decision.route == ActivationRoute.BYPASS:
562
+ return EngineResponse(None, traces, bypass=True)
563
+ if decision.route == ActivationRoute.PROTOCOL_DISCUSSION:
564
+ return EngineResponse(self._call(
565
+ "ANSWER_PROTOCOL_DISCUSSION",
566
+ {
567
+ "RAW_PROTOCOL_QUESTION": user_message,
568
+ "CURRENT_STAGE_CLASS": None,
569
+ "BOUND_REVIEW_SUBJECT_KIND": None,
570
+ "BOUND_REVIEW_SUBJECT_BODY": None,
571
+ },
572
+ traces,
573
+ parser=self.bridge.parse_protocol_discussion,
574
+ ), traces)
575
+ return self._draft_initial_prompt(
576
+ user_message.strip(),
577
+ traces,
578
+ protocol_state="ACTIVE_BY_SEMANTIC_REQUEST",
579
+ )
580
+
581
+ def _draft_plan(self, transition: Transition, traces: list[CallTrace]) -> EngineResponse:
582
+ assert self.controller is not None and self.workspace is not None
583
+ prompt = self.controller.state.current_prompt
584
+ assert prompt is not None
585
+ self.workspace.validate_confirmed_artifact("prompt", prompt.artifact_id, prompt.body)
586
+ _, prompt_body = self.workspace.read_artifact("prompt")
587
+ carried_raw = self.workspace.read_approach_sources()
588
+ if carried_raw != self.controller.state.approach_sources:
589
+ raise WorkspaceError("approach_source_handoff")
590
+ carried = [self._compile_context(s, traces) for s in carried_raw]
591
+ body = self._call(
592
+ "DRAFT_PLAN",
593
+ {
594
+ "CONFIRMED_PROMPT_BODY": prompt_body,
595
+ "CARRIED_APPROACH_SOURCES": carried,
596
+ },
597
+ traces,
598
+ parser=self.bridge.parse_plan_body,
599
+ )
600
+ self.controller.commit_plan(body)
601
+ self._publish_plan()
602
+ return EngineResponse(presentation.plan_artifact(body), traces)
603
+
604
+ def _revise_prompt(self, transition: Transition, traces: list[CallTrace]) -> EngineResponse:
605
+ assert self.controller is not None and self.workspace is not None
606
+ prompt = self.controller.state.current_prompt
607
+ assert prompt is not None
608
+ meta, prompt_body = self.workspace.read_artifact("prompt")
609
+ if meta.get("artifact_id") != prompt.artifact_id or prompt_body != prompt.body:
610
+ raise WorkspaceError("prompt_revision_handoff")
611
+ change_id = transition.payload["change_id"]
612
+ had_plan = self.controller.state.current_plan is not None
613
+ try:
614
+ body = self._call(
615
+ "REVISE_PROMPT",
616
+ {
617
+ "CURRENT_PROMPT_BODY": prompt_body,
618
+ # Protocol v2: raw change source routed through the
619
+ # semantic read; compile op receives the compiled form.
620
+ "TASK_CHANGE_SOURCE": self._compile_context(
621
+ transition.payload["task_change_source"], traces
622
+ ),
623
+ },
624
+ traces,
625
+ parser=self.bridge.parse_prompt_body,
626
+ )
627
+ self.controller.commit_prompt_revision(change_id, body)
628
+ # Mechanical coverage regression check on revisions: entities the
629
+ # draft carried must survive revision. Event-only in v1 (no loop).
630
+ missing = self._entity_coverage_missing(body, self._active_task_entities)
631
+ if missing and self.workspace is not None:
632
+ self.workspace.append_event(
633
+ "TASK_ENTITY_COVERAGE_MISSING",
634
+ {"entities": list(missing), "phase": "revise_prompt"},
635
+ )
636
+ except Exception:
637
+ self.controller.abort_pending_change(change_id)
638
+ raise
639
+ if had_plan:
640
+ self.workspace.invalidate_artifact("plan", "prompt_revision")
641
+ self._publish_prompt()
642
+ return EngineResponse(presentation.prompt_artifact(body), traces)
643
+
644
+ def _revise_plan(self, transition: Transition, traces: list[CallTrace]) -> EngineResponse:
645
+ assert self.controller is not None and self.workspace is not None
646
+ prompt = self.controller.state.current_prompt
647
+ plan = self.controller.state.current_plan
648
+ assert prompt is not None and plan is not None
649
+ self.workspace.validate_confirmed_artifact("prompt", prompt.artifact_id, prompt.body)
650
+ prompt_body = self.workspace.read_artifact("prompt")[1]
651
+ plan_meta, plan_body = self.workspace.read_artifact("plan")
652
+ if plan_meta.get("artifact_id") != plan.artifact_id or plan_body != plan.body:
653
+ raise WorkspaceError("plan_revision_handoff")
654
+ change_id = transition.payload["change_id"]
655
+ carried_raw = self.workspace.read_approach_sources()
656
+ if carried_raw != self.controller.state.approach_sources:
657
+ raise WorkspaceError("approach_source_handoff")
658
+ try:
659
+ body = self._call(
660
+ "REVISE_PLAN",
661
+ {
662
+ "CONFIRMED_PROMPT_BODY": prompt_body,
663
+ "CURRENT_PLAN_BODY": plan_body,
664
+ "CARRIED_APPROACH_SOURCES": [
665
+ *map(lambda s: self._compile_context(s, traces), carried_raw),
666
+ self._compile_context(transition.payload["approach_change_source"], traces),
667
+ ],
668
+ },
669
+ traces,
670
+ parser=self.bridge.parse_plan_body,
671
+ )
672
+ self.controller.commit_plan_revision(change_id, body)
673
+ except Exception:
674
+ self.controller.abort_pending_change(change_id)
675
+ raise
676
+ self._publish_plan()
677
+ return EngineResponse(presentation.plan_artifact(body), traces)
678
+
679
+ def _execute(self, transition: Transition, traces: list[CallTrace]) -> EngineResponse:
680
+ assert self.controller is not None and self.workspace is not None
681
+ if not self.controller.can_execute():
682
+ raise ControllerError("execute_gate")
683
+ prompt = self.controller.state.current_prompt
684
+ plan = self.controller.state.current_plan
685
+ assert prompt is not None and plan is not None
686
+ self.workspace.validate_confirmed_artifact("prompt", prompt.artifact_id, prompt.body)
687
+ self.workspace.validate_confirmed_artifact("plan", plan.artifact_id, plan.body)
688
+ prompt_body = self.workspace.read_artifact("prompt")[1]
689
+ plan_body = self.workspace.read_artifact("plan")[1]
690
+ # Protocol v2: the supplied execution input is raw user content (in the
691
+ # adversarial battery it IS the untrusted block). Route it through the
692
+ # semantic read — compile ops never receive raw content in any symbol.
693
+ supplied_raw = transition.payload.get("execution_input_source")
694
+ supplied_compiled = self._compile_context(supplied_raw, traces) if supplied_raw else None
695
+ # Cross-epoch deliverable chaining (ADR-0008 §4 + ADR-0004): the prior
696
+ # epoch's confirmed deliverable is host-published, gate-passed content
697
+ # (see _semantic_read S4 note). Chain it byte-exact into the execution
698
+ # phase so continuation/revision epochs condition on the actual prior
699
+ # artifact instead of a pseudocode summary. First epochs have no prior
700
+ # deliverable and keep the previous null behavior.
701
+ required_task_inputs = self._previous_deliverable
702
+ # ADR-0009 / TRD-0003 (RS-09, RS-10): feature-gated Result IR mode.
703
+ # Adds structured result-decomposition instructions, chains the prior
704
+ # validated Result IR beside the deliverable, and mechanically
705
+ # validates the emitted IR (coverage + evidence resolution).
706
+ result_ir_mode = os.environ.get("PDLT_RESULT_IR") == "1"
707
+ from pdl_taskmaster.runtime.result_ir import (
708
+ arithmetic_checks,
709
+ derive_requirements,
710
+ entity_enforcement_misses,
711
+ extract_result_ir,
712
+ load_ir_from_deliverable,
713
+ parse_typed_entities,
714
+ render_execution_brief,
715
+ render_instructions,
716
+ render_prior_ir_section,
717
+ validate_result_ir,
718
+ )
719
+ requirements = derive_requirements(prompt_body) if result_ir_mode else []
720
+ execute_context = {
721
+ "CONFIRMED_PROMPT_BODY": prompt_body,
722
+ "CONFIRMED_PLAN_BODY": plan_body,
723
+ "REQUIRED_TASK_INPUTS": required_task_inputs,
724
+ "SUPPLIED_EXECUTION_INPUT_SOURCE": supplied_compiled,
725
+ "AVAILABLE_EXECUTION_TOOLS": self.available_execution_tools,
726
+ }
727
+ if result_ir_mode:
728
+ # TRD-0003: the IR channel rides inside the contract-whitelisted
729
+ # REQUIRED_TASK_INPUTS symbol (adding new symbols would violate the
730
+ # EXECUTION_CONTRACT allow-list). Value = instructions + numbered
731
+ # requirements + (when chained) the prior validated IR.
732
+ evidence_paths = ["execution://body"]
733
+ if self._previous_deliverable and self.workspace.closed_turns():
734
+ closed = [t for t in self.workspace.closed_turns() if t.get("status") == "CLOSED_SUCCESS"]
735
+ if closed:
736
+ evidence_paths.append(
737
+ f"turns/{closed[-1]['turn_id']}/stages/50_execution/output/current.md"
738
+ )
739
+ ir_channel = render_instructions(
740
+ requirements, repo_root=self.repo_root, evidence_paths=evidence_paths
741
+ )
742
+ prior_ir = load_ir_from_deliverable(self._previous_deliverable)
743
+ if prior_ir:
744
+ ir_channel += render_prior_ir_section(prior_ir)
745
+ ir_channel += render_execution_brief(prior_ir, requirements)
746
+ base_inputs = required_task_inputs or ""
747
+ channel_value = (base_inputs + ir_channel) if base_inputs else ir_channel
748
+ # Delivery markers are synthesized BEFORE the draft (4B class): the
749
+ # brief must pin them so the first emission is correctly labeled
750
+ # and no full re-emission is needed.
751
+ declared_names = sorted(
752
+ set(
753
+ re.findall(
754
+ r"\b[\w-]+\.(?:py|md|json|txt|cfg|toml)\b",
755
+ prompt_body + "\n" + plan_body + "\n" + channel_value,
756
+ )
757
+ )
758
+ )
759
+ marker_entities = tuple(f"### {n}" for n in declared_names)
760
+ if marker_entities:
761
+ channel_value += (
762
+ "\nDELIVERY FORMAT: file sections MUST be headed exactly by their marker lines "
763
+ "(e.g. a line reading exactly '### cnf.py' immediately followed by that file's code): "
764
+ + "; ".join(marker_entities)
765
+ )
766
+ # DRAFT_EXECUTE (ADR-0009): entity extraction at the execute
767
+ # boundary, mirroring DRAFT_PROMPT's task-entity channel. Turns are
768
+ # structurally lossy; the draft declares the verbatim-critical
769
+ # entities (wire formats, constants, signatures) before any code is
770
+ # written, and the host enforces their survival mechanically.
771
+ draft_context = {
772
+ "HOST_PROTOCOL_STATE": "EXECUTION_DRAFT",
773
+ "CONFIRMED_PROMPT_BODY": prompt_body,
774
+ "CONFIRMED_PLAN_BODY": plan_body,
775
+ "REQUIRED_TASK_INPUTS": channel_value,
776
+ }
777
+ draft = self._call(
778
+ "DRAFT_EXECUTE",
779
+ draft_context,
780
+ traces,
781
+ parser=self.bridge.parse_execution_draft,
782
+ )
783
+ # Typed entity pipeline (parser over entities, not string filters):
784
+ # the host classifies, arithmetically validates, and applies
785
+ # kind-appropriate enforcement. meta entities are never enforced
786
+ # against the deliverable.
787
+ typed = parse_typed_entities(draft.execution_entities)
788
+ arithmetic = arithmetic_checks(typed)
789
+ if arithmetic:
790
+ self.workspace.append_event(
791
+ "EXECUTE_DRAFT_ARITHMETIC_RETRY", {"errors": arithmetic}
792
+ )
793
+ draft_context["REQUIRED_TASK_INPUTS"] = channel_value + (
794
+ "\n\n\n\nOPERATOR CORRECTION (host-side mechanical check): the declared wire-format "
795
+ "arithmetic is inconsistent and MUST be fixed before execution: "
796
+ + " | ".join(arithmetic)
797
+ )
798
+ draft = self._call(
799
+ "DRAFT_EXECUTE",
800
+ draft_context,
801
+ traces,
802
+ parser=self.bridge.parse_execution_draft,
803
+ )
804
+ typed = parse_typed_entities(draft.execution_entities)
805
+ arithmetic = arithmetic_checks(typed)
806
+ if arithmetic:
807
+ self.workspace.append_event(
808
+ "EXECUTE_DRAFT_ARITHMETIC_INVALID",
809
+ {"errors": arithmetic, "scored": "model_error"},
810
+ )
811
+ containment_pool = channel_value + prompt_body + plan_body
812
+ kept = [e for e in typed if _norm(e["value"]) in _norm(containment_pool)]
813
+ dropped = len(typed) - len(kept)
814
+ if dropped:
815
+ self.workspace.append_event(
816
+ "EXECUTE_ENTITY_DROPPED_UNSAFE", {"count": dropped}
817
+ )
818
+ # Delivery markers are always enforced entities (4B class): the
819
+ # section headers themselves are lossy across turns.
820
+ declared_names = sorted(
821
+ set(
822
+ re.findall(
823
+ r"[\w-]+\.(?:py|md|json|txt|cfg|toml)",
824
+ prompt_body + " " + plan_body + " " + channel_value,
825
+ )
826
+ )
827
+ )
828
+ existing_values = {e["value"] for e in kept}
829
+ kept = kept + [
830
+ {"kind": "delivery_marker", "value": f"### {n}", "name": None,
831
+ "struct_format": None, "declared_size": None}
832
+ for n in declared_names
833
+ if f"### {n}" not in existing_values
834
+ ]
835
+ brief_misses, _ = entity_enforcement_misses(kept, draft.brief_body, None)
836
+ if brief_misses:
837
+ self.workspace.append_event(
838
+ "EXECUTE_ENTITY_COVERAGE_RETRY", {"missing": len(brief_misses)}
839
+ )
840
+ draft_context["REQUIRED_TASK_INPUTS"] = channel_value + (
841
+ "\n\n\n\nOPERATOR CORRECTION (host-side mechanical check): the following execution "
842
+ "entities are missing from the brief body and MUST appear verbatim, "
843
+ "character-for-character: " + " | ".join(brief_misses)
844
+ )
845
+ draft = self._call(
846
+ "DRAFT_EXECUTE",
847
+ draft_context,
848
+ traces,
849
+ parser=self.bridge.parse_execution_draft,
850
+ )
851
+ entity_block = (
852
+ "\n## EXECUTION BRIEF (entity-dense; drafted prior to execution)\n"
853
+ + draft.brief_body
854
+ + "\nCRITICAL VERBATIM ENTITIES (each MUST appear verbatim in the deliverable; "
855
+ "the host checks this mechanically):\n"
856
+ + "\n" + "\n".join(f"- [{e['kind']}] {e['value']}]" for e in kept)
857
+ + "\nDELIVERY FORMAT: file sections MUST be headed exactly by their marker lines "
858
+ "(e.g. a line reading exactly '### cnf.py' immediately followed by that file's code)."
859
+ )
860
+ execute_context["REQUIRED_TASK_INPUTS"] = (
861
+ (base_inputs + entity_block + ir_channel) if base_inputs else (entity_block + ir_channel)
862
+ )
863
+ execute_entities = kept
864
+ else:
865
+ execute_entities = ()
866
+ outcome = self._call(
867
+ "EXECUTE",
868
+ execute_context,
869
+ traces,
870
+ parser=self.bridge.parse_execution,
871
+ )
872
+ final_body = outcome.body
873
+ if result_ir_mode and outcome.kind == "RESULT":
874
+ workspace_path = self.workspace.path
875
+ attempts = 0
876
+ repaired = False
877
+ while True:
878
+ wire_ir = getattr(outcome, "result_ir", None)
879
+ if isinstance(wire_ir, dict):
880
+ # Option 2 (ADR-0009): the IR arrived as a schema-enforced
881
+ # wire field; body scanning is only the recorded-path
882
+ # fallback.
883
+ ir = wire_ir
884
+ else:
885
+ ir = extract_result_ir(final_body)
886
+ if ir is not None:
887
+ ir_errors, _ = validate_result_ir(
888
+ ir, workspace_path, requirements, execution_body=final_body
889
+ )
890
+ else:
891
+ ir_errors = ["Result IR missing or not a JSON object (TRD-0003 RS-01)"]
892
+ _, entity_missing = entity_enforcement_misses(
893
+ execute_entities, "", final_body
894
+ )
895
+ if entity_missing:
896
+ self.workspace.append_event(
897
+ "EXECUTE_ENTITY_MISSING", {"count": len(entity_missing)}
898
+ )
899
+ errors = ir_errors + [
900
+ f"critical execution entity missing from the deliverable: {e!r}" for e in entity_missing
901
+ ]
902
+ if not errors or attempts >= 2:
903
+ break
904
+ attempts += 1
905
+ if not entity_missing:
906
+ # IR-only repair (TRD-0003 RS-08): a dedicated
907
+ # schema-enforced repair op re-emits ONLY the corrected
908
+ # result_ir, so the retry budget affords multiple cheap
909
+ # attempts instead of one full re-emission.
910
+ repair_ctx = {
911
+ "HOST_PROTOCOL_STATE": "RESULT_IR_REPAIR",
912
+ "DELIVERABLE_BODY": final_body,
913
+ "RESULT_IR_REQUIREMENTS": render_instructions(
914
+ requirements,
915
+ repo_root=self.repo_root,
916
+ evidence_paths=evidence_paths,
917
+ ),
918
+ "RESULT_IR_ERRORS": " | ".join(ir_errors),
919
+ }
920
+ repair = self._call(
921
+ "EMIT_RESULT_IR",
922
+ repair_ctx,
923
+ traces,
924
+ parser=self.bridge.parse_result_ir_repair,
925
+ )
926
+ ir2 = repair.get("result_ir") if isinstance(repair, dict) else None
927
+ if ir2 is not None:
928
+ e2, _ = validate_result_ir(
929
+ ir2, workspace_path, requirements, execution_body=final_body
930
+ )
931
+ if not e2:
932
+ fence_open = "\n\n```json\n"
933
+ fence_close = "\n```"
934
+ final_body = (
935
+ final_body
936
+ + "\n\n"
937
+ + fence_open
938
+ + json.dumps(ir2, indent=2, ensure_ascii=False)
939
+ + fence_close
940
+ )
941
+ repaired = True
942
+ self.workspace.append_event(
943
+ "RESULT_IR_REPAIRED", {"attempts": attempts}
944
+ )
945
+ break
946
+ ir_errors = e2
947
+ self.workspace.append_event(
948
+ "RESULT_IR_REPAIR_RETRY", {"attempt": attempts}
949
+ )
950
+ continue
951
+ # Code-defect retry: the deliverable itself is wrong; a full
952
+ # re-emission is unavoidable (option 2: body + IR travel
953
+ # together under the wire schema).
954
+ correction_ctx = dict(execute_context)
955
+ correction_ctx["REQUIRED_TASK_INPUTS"] = execute_context["REQUIRED_TASK_INPUTS"] + (
956
+ "\\n\\nRESULT IR VALIDATION ERRORS (host-side mechanical check): fix ONLY these violations and re-emit the FULL response with a corrected result_ir: "
957
+ + " | ".join(errors)
958
+ )
959
+ outcome = self._call(
960
+ "EXECUTE",
961
+ correction_ctx,
962
+ traces,
963
+ parser=self.bridge.parse_execution,
964
+ )
965
+ final_body = outcome.body
966
+ if repaired:
967
+ self.workspace.append_event(
968
+ "RESULT_IR_VALIDATED", {"ir": ir, "repaired": True}
969
+ )
970
+ elif errors:
971
+ self.workspace.append_event(
972
+ "RESULT_IR_INVALID", {"errors": errors, "scored": "model_error"}
973
+ )
974
+ else:
975
+ self.workspace.append_event("RESULT_IR_VALIDATED", {"ir": ir})
976
+ if outcome.kind == "REQUEST_INPUT":
977
+ assert outcome.expected_type is not None and outcome.description is not None
978
+ self.controller.request_execution_input(outcome.expected_type, outcome.description)
979
+ self.workspace.publish_execution_outcome(
980
+ outcome.kind,
981
+ outcome.body,
982
+ {"expected_type": outcome.expected_type, "description": outcome.description},
983
+ )
984
+ return EngineResponse(outcome.body, traces)
985
+ if outcome.kind == "BLOCKED_BY_HIGHER_PRIORITY":
986
+ self.controller.cancel()
987
+ self.workspace.publish_execution_outcome(outcome.kind, outcome.body)
988
+ return EngineResponse(outcome.body, traces, closed=True)
989
+ result_body_hash = hashlib.sha256(final_body.encode("utf-8")).hexdigest()
990
+ self.controller.complete_success(result_body_hash)
991
+ if self.workspace.turn_id is not None:
992
+ self.workspace.mark_turn_status("CLOSED_SUCCESS", deliverable_sha256=result_body_hash)
993
+ self.workspace.publish_execution_outcome(
994
+ outcome.kind,
995
+ final_body,
996
+ {
997
+ "source_prompt_id": prompt.artifact_id,
998
+ "source_plan_id": plan.artifact_id,
999
+ "result_body_hash": result_body_hash,
1000
+ "confirmed_prompt_hash": hashlib.sha256(prompt.body.encode("utf-8")).hexdigest(),
1001
+ "confirmed_plan_hash": hashlib.sha256(plan.body.encode("utf-8")).hexdigest(),
1002
+ },
1003
+ )
1004
+ return EngineResponse(final_body, traces, closed=True)
1005
+
1006
+ def _answer_protocol(self, user_message: str, traces: list[CallTrace]) -> EngineResponse:
1007
+ assert self.controller is not None
1008
+ kind, body = self.controller.review_subject()
1009
+ return EngineResponse(self._call(
1010
+ "ANSWER_PROTOCOL_DISCUSSION",
1011
+ {
1012
+ "RAW_PROTOCOL_QUESTION": user_message,
1013
+ "CURRENT_STAGE_CLASS": self.controller.state.stage.value,
1014
+ "BOUND_REVIEW_SUBJECT_KIND": kind,
1015
+ "BOUND_REVIEW_SUBJECT_BODY": body,
1016
+ },
1017
+ traces,
1018
+ parser=self.bridge.parse_protocol_discussion,
1019
+ ), traces)
1020
+
1021
+ def _apply_transition(self, transition: Transition, user_message: str, traces: list[CallTrace]) -> EngineResponse:
1022
+ if transition.action == NextAction.DRAFT_PLAN:
1023
+ return self._draft_plan(transition, traces)
1024
+ if transition.action == NextAction.REVISE_PROMPT:
1025
+ return self._revise_prompt(transition, traces)
1026
+ if transition.action == NextAction.REVISE_PLAN:
1027
+ return self._revise_plan(transition, traces)
1028
+ if transition.action == NextAction.EXECUTE:
1029
+ return self._execute(transition, traces)
1030
+ if transition.action == NextAction.ANSWER_PROTOCOL:
1031
+ return self._answer_protocol(user_message, traces)
1032
+ if transition.action == NextAction.DEFER_SUBSTANTIVE:
1033
+ return EngineResponse(presentation.deferred_substantive(), traces)
1034
+ if transition.action == NextAction.REQUEST_REVIEW_CLARIFICATION:
1035
+ return EngineResponse(presentation.review_clarification(), traces)
1036
+ if transition.action == NextAction.SHOW_CURRENT_PROMPT:
1037
+ assert self.controller is not None and self.workspace is not None
1038
+ prompt = self.controller.state.current_prompt
1039
+ assert prompt is not None
1040
+ self.workspace.publish_approach_sources(list(self.controller.state.approach_sources))
1041
+ return EngineResponse(presentation.prompt_artifact(self.workspace.read_artifact("prompt")[1]), traces)
1042
+ if transition.action == NextAction.CLOSED:
1043
+ assert self.workspace is not None
1044
+ self.workspace.append_event("PROTOCOL_CLOSED", {"reason": "cancelled"})
1045
+ return EngineResponse(presentation.cancelled(), traces, closed=True)
1046
+ if transition.action == NextAction.START_NEW_INSTANCE:
1047
+ new_task = transition.payload["new_task_source"]
1048
+ assert self.workspace is not None
1049
+ self.workspace.append_event("PROTOCOL_CLOSED", {"reason": "new_task"})
1050
+ self.workspace = self._new_workspace()
1051
+ self.controller = None
1052
+ return self._draft_initial_prompt(
1053
+ new_task,
1054
+ traces,
1055
+ protocol_state="ACTIVE_FRESH_INSTANCE_FROM_REVIEW",
1056
+ )
1057
+ raise ControllerError(f"transition:{transition.action.value}")
1058
+
1059
+ def handle_explicit_review(self, intent: Intent, feedback: str | None = None) -> EngineResponse:
1060
+ """Directly apply a review intent without LLM interpretation overhead (fast-path)."""
1061
+ traces: list[CallTrace] = []
1062
+ if self.workspace is None:
1063
+ raise WorkspaceError("active_controller_without_workspace")
1064
+ if self.controller is None:
1065
+ raise ControllerError("user_message_stage")
1066
+ # Durability (ADR-0009 workflow): interrupted internal transitions are
1067
+ # re-driven by explicit user input instead of bricking the epoch.
1068
+ if self.controller.state.stage == Stage.PLAN_REQUIRED:
1069
+ return self._draft_plan(Transition(NextAction.DRAFT_PLAN, {}), traces)
1070
+ if self.controller.state.stage == Stage.EXECUTION_READY:
1071
+ return self._execute(Transition(NextAction.EXECUTE, {}), traces)
1072
+ if self.controller.state.stage not in {Stage.PROMPT_REVIEW, Stage.PLAN_REVIEW, Stage.WAITING_INPUT}:
1073
+ raise ControllerError("user_message_stage")
1074
+
1075
+ self._sync_review_edit()
1076
+ previous_stage = self.controller.state.stage
1077
+ decision = ReviewDecision(intent=intent)
1078
+ msg = feedback or ""
1079
+ transition = self.controller.apply_review_decision(decision, msg)
1080
+ if decision.intent == Intent.ACCEPT_CURRENT:
1081
+ if previous_stage == Stage.PROMPT_REVIEW:
1082
+ prompt = self.controller.state.current_prompt
1083
+ assert prompt is not None
1084
+ self.workspace.mark_artifact_confirmed("prompt", prompt.artifact_id)
1085
+ elif previous_stage == Stage.PLAN_REVIEW:
1086
+ plan = self.controller.state.current_plan
1087
+ assert plan is not None
1088
+ self.workspace.mark_artifact_confirmed("plan", plan.artifact_id)
1089
+ if decision.intent == Intent.REVISE_APPROACH and previous_stage == Stage.PROMPT_REVIEW:
1090
+ self.workspace.publish_approach_sources(list(self.controller.state.approach_sources))
1091
+ return self._apply_transition(transition, msg, traces)
1092
+
1093
+ def handle_user_message(self, user_message: str) -> EngineResponse:
1094
+ traces: list[CallTrace] = []
1095
+ if self.controller is None or self.controller.state.stage in {Stage.CLOSED_SUCCESS, Stage.CLOSED_CANCELLED}:
1096
+ return self._activation(user_message, traces) or EngineResponse(None, traces, bypass=True)
1097
+
1098
+ if self.workspace is None:
1099
+ raise WorkspaceError("active_controller_without_workspace")
1100
+ # Durability (ADR-0009 workflow): interrupted internal transitions are
1101
+ # re-driven by any user input instead of bricking the epoch.
1102
+ if self.controller.state.stage == Stage.PLAN_REQUIRED:
1103
+ return self._draft_plan(Transition(NextAction.DRAFT_PLAN, {}), traces)
1104
+ if self.controller.state.stage == Stage.EXECUTION_READY:
1105
+ return self._execute(Transition(NextAction.EXECUTE, {}), traces)
1106
+ if self.controller.state.stage not in {Stage.PROMPT_REVIEW, Stage.PLAN_REVIEW, Stage.WAITING_INPUT}:
1107
+ raise ControllerError("user_message_stage")
1108
+
1109
+ stripped = user_message.strip()
1110
+ # Silence-deferral fix (Finding D3): do not route empty/whitespace input to LLM interpretation
1111
+ if not stripped:
1112
+ artifact_name = "prompt pseudocode" if self.controller.state.stage == Stage.PROMPT_REVIEW else "execution plan"
1113
+ return EngineResponse(
1114
+ f"Please confirm the {artifact_name} (type /confirm or press Enter with confirmation) or specify revisions (/revise <feedback>).",
1115
+ traces,
1116
+ )
1117
+
1118
+ # Fast-path commands
1119
+ lower = stripped.lower()
1120
+ if lower == "/confirm":
1121
+ return self.handle_explicit_review(Intent.ACCEPT_CURRENT)
1122
+ if lower.startswith("/revise"):
1123
+ fb = stripped[7:].strip()
1124
+ if not fb:
1125
+ return EngineResponse("Please specify your revisions: /revise <feedback>", traces)
1126
+ target_intent = (
1127
+ Intent.REVISE_TASK
1128
+ if self.controller.state.stage == Stage.PROMPT_REVIEW
1129
+ else Intent.REVISE_APPROACH
1130
+ )
1131
+ return self.handle_explicit_review(target_intent, fb)
1132
+ if lower == "/stop":
1133
+ return self.handle_explicit_review(Intent.CANCEL)
1134
+
1135
+ # Standard LLM review interpretation
1136
+ self._sync_review_edit()
1137
+ subject_kind, subject_body = self.controller.review_subject()
1138
+ previous_stage = self.controller.state.stage
1139
+ operation, parser = {
1140
+ Stage.PROMPT_REVIEW: ("INTERPRET_PROMPT_REVIEW", self.bridge.parse_prompt_review),
1141
+ Stage.PLAN_REVIEW: ("INTERPRET_PLAN_REVIEW", self.bridge.parse_plan_review),
1142
+ Stage.WAITING_INPUT: ("INTERPRET_EXECUTION_INPUT", self.bridge.parse_execution_input),
1143
+ }[previous_stage]
1144
+ decision = ReviewDecision.from_dict(self._call(
1145
+ operation,
1146
+ {
1147
+ "BOUND_REVIEW_SUBJECT_KIND": subject_kind,
1148
+ "BOUND_REVIEW_SUBJECT_BODY": subject_body,
1149
+ "RAW_USER_REVIEW_MESSAGE": user_message,
1150
+ },
1151
+ traces,
1152
+ parser=parser,
1153
+ ))
1154
+ transition = self.controller.apply_review_decision(decision, user_message)
1155
+
1156
+ if decision.intent == Intent.ACCEPT_CURRENT:
1157
+ if previous_stage == Stage.PROMPT_REVIEW:
1158
+ prompt = self.controller.state.current_prompt
1159
+ assert prompt is not None
1160
+ self.workspace.mark_artifact_confirmed("prompt", prompt.artifact_id)
1161
+ elif previous_stage == Stage.PLAN_REVIEW:
1162
+ plan = self.controller.state.current_plan
1163
+ assert plan is not None
1164
+ self.workspace.mark_artifact_confirmed("plan", plan.artifact_id)
1165
+ if decision.intent == Intent.REVISE_APPROACH and previous_stage == Stage.PROMPT_REVIEW:
1166
+ self.workspace.publish_approach_sources(list(self.controller.state.approach_sources))
1167
+
1168
+ return self._apply_transition(transition, user_message, traces)