tenseleyflow/loader / 82a0ca7

Browse files

Add typed workflow signal extraction

Authored by espadonne
SHA
82a0ca7faa669e27386a80e93191ea14ec673abd
Parents
1aaccad
Tree
a0cc95f

6 changed files

StatusFile+-
M src/loader/runtime/conversation.py 35 22
M src/loader/runtime/workflow.py 3 0
M src/loader/runtime/workflow_policy.py 71 25
A src/loader/runtime/workflow_signals.py 221 0
M tests/test_workflow_policy.py 20 0
A tests/test_workflow_signals.py 55 0
src/loader/runtime/conversation.pymodified
@@ -35,6 +35,7 @@ from .workflow import (
3535
     WorkflowDecisionKind,
3636
     WorkflowMode,
3737
     WorkflowPolicy,
38
+    WorkflowSignalExtractor,
3839
     WorkflowTimelineEntry,
3940
     WorkflowTimelineEntryKind,
4041
     build_execute_bridge,
@@ -54,7 +55,8 @@ class ConversationRuntime:
5455
         self.tracer = RuntimeTracer()
5556
         self.executor: ToolExecutor | None = None
5657
         self.dod_store = DefinitionOfDoneStore(agent.project_root)
57
-        self.workflow_policy = WorkflowPolicy()
58
+        self.workflow_signals = WorkflowSignalExtractor()
59
+        self.workflow_policy = WorkflowPolicy(self.workflow_signals)
5860
         self.artifact_store = WorkflowArtifactStore(agent.project_root)
5961
         self.turn_requester = AssistantTurnRequester(agent, self.tracer)
6062
         self.tool_batches = ToolBatchRunner(agent, self.dod_store)
@@ -488,12 +490,15 @@ class ConversationRuntime:
488490
         requested_mode: str | None,
489491
     ) -> str:
490492
         requested = WorkflowMode.from_str(requested_mode)
491
-        decision = self.workflow_policy.route(
492
-            task,
493
-            requested_mode=requested,
494
-            has_brief=self._artifact_exists(dod.clarify_brief),
495
-            has_plan=self._artifact_exists(dod.implementation_plan)
496
-            and self._artifact_exists(dod.verification_plan),
493
+        decision = self.workflow_policy.route_from_signals(
494
+            self.workflow_signals.extract_route_signals(
495
+                task,
496
+                requested_mode=requested.value if requested is not None else None,
497
+                has_brief=self._artifact_exists(dod.clarify_brief),
498
+                has_plan=self._artifact_exists(dod.implementation_plan)
499
+                and self._artifact_exists(dod.verification_plan),
500
+                timeline=self.agent.session.workflow_timeline,
501
+            )
497502
         )
498503
         await self._set_workflow_mode(
499504
             decision,
@@ -516,13 +521,16 @@ class ConversationRuntime:
516521
                 summary=summary,
517522
                 on_user_question=on_user_question,
518523
             )
519
-            decision = self.workflow_policy.route(
520
-                task,
521
-                has_brief=self._artifact_exists(dod.clarify_brief),
522
-                has_plan=self._artifact_exists(dod.implementation_plan)
523
-                and self._artifact_exists(dod.verification_plan),
524
-                allow_clarify=False,
525
-                unresolved_questions=clarify_review.unresolved_questions,
524
+            decision = self.workflow_policy.route_from_signals(
525
+                self.workflow_signals.extract_route_signals(
526
+                    task,
527
+                    has_brief=self._artifact_exists(dod.clarify_brief),
528
+                    has_plan=self._artifact_exists(dod.implementation_plan)
529
+                    and self._artifact_exists(dod.verification_plan),
530
+                    allow_clarify=False,
531
+                    unresolved_questions=clarify_review.unresolved_questions,
532
+                    timeline=self.agent.session.workflow_timeline,
533
+                )
526534
             )
527535
             await self._set_workflow_mode(
528536
                 decision.with_context(
@@ -1096,14 +1104,19 @@ class ConversationRuntime:
10961104
         if not freshness.stale_plan:
10971105
             return False
10981106
 
1099
-        decision = self.workflow_policy.route(
1100
-            task,
1101
-            has_brief=self._artifact_exists(dod.clarify_brief),
1102
-            has_plan=True,
1103
-            allow_clarify=False,
1104
-            stale_plan=True,
1105
-            verification_pressure=bool(dod.retry_count or dod.last_verification_result == "failed"),
1106
-            unresolved_questions=freshness.reasons,
1107
+        decision = self.workflow_policy.route_from_signals(
1108
+            self.workflow_signals.extract_route_signals(
1109
+                task,
1110
+                has_brief=self._artifact_exists(dod.clarify_brief),
1111
+                has_plan=True,
1112
+                allow_clarify=False,
1113
+                stale_plan=True,
1114
+                verification_pressure=bool(
1115
+                    dod.retry_count or dod.last_verification_result == "failed"
1116
+                ),
1117
+                unresolved_questions=freshness.reasons,
1118
+                timeline=self.agent.session.workflow_timeline,
1119
+            )
11071120
         )
11081121
         await self._set_workflow_mode(
11091122
             decision,
src/loader/runtime/workflow.pymodified
@@ -19,6 +19,7 @@ from .workflow_policy import (
1919
     WorkflowTimelineEntry,
2020
     WorkflowTimelineEntryKind,
2121
 )
22
+from .workflow_signals import WorkflowSignalExtractor, WorkflowSignalPacket
2223
 
2324
 __all__ = [
2425
     "ArtifactFreshness",
@@ -32,6 +33,8 @@ __all__ = [
3233
     "WorkflowDecisionKind",
3334
     "WorkflowMode",
3435
     "WorkflowPolicy",
36
+    "WorkflowSignalExtractor",
37
+    "WorkflowSignalPacket",
3538
     "WorkflowTimelineEntry",
3639
     "WorkflowTimelineEntryKind",
3740
     "build_execute_bridge",
src/loader/runtime/workflow_policy.pymodified
@@ -9,6 +9,8 @@ from enum import StrEnum
99
 from pathlib import Path
1010
 from typing import Any
1111
 
12
+from .workflow_signals import WorkflowSignalExtractor, WorkflowSignalPacket
13
+
1214
 
1315
 class WorkflowMode(StrEnum):
1416
     """High-level runtime modes for one Loader task turn."""
@@ -68,6 +70,7 @@ class ModeDecision:
6870
     scheduled_next_mode: WorkflowMode | None = None
6971
     unresolved_questions: list[str] = field(default_factory=list)
7072
     pressure_summary: list[str] = field(default_factory=list)
73
+    signal_summary: list[str] = field(default_factory=list)
7174
 
7275
     @property
7376
     def reason(self) -> str:
@@ -89,6 +92,7 @@ class ModeDecision:
8992
         scheduled_next_mode: WorkflowMode | None = None,
9093
         unresolved_questions: list[str] | None = None,
9194
         pressure_summary: list[str] | None = None,
95
+        signal_summary: list[str] | None = None,
9296
     ) -> ModeDecision:
9397
         """Build a non-router workflow decision for handoffs and reentry."""
9498
 
@@ -105,6 +109,7 @@ class ModeDecision:
105109
             scheduled_next_mode=scheduled_next_mode,
106110
             unresolved_questions=list(unresolved_questions or []),
107111
             pressure_summary=list(pressure_summary or []),
112
+            signal_summary=list(signal_summary or []),
108113
         )
109114
 
110115
     def with_context(
@@ -119,6 +124,7 @@ class ModeDecision:
119124
         scheduled_next_mode: WorkflowMode | None = None,
120125
         unresolved_questions: list[str] | None = None,
121126
         pressure_summary: list[str] | None = None,
127
+        signal_summary: list[str] | None = None,
122128
     ) -> ModeDecision:
123129
         """Return a copy with updated contextual routing metadata."""
124130
 
@@ -149,6 +155,9 @@ class ModeDecision:
149155
                 if pressure_summary is None
150156
                 else pressure_summary
151157
             ),
158
+            signal_summary=list(
159
+                self.signal_summary if signal_summary is None else signal_summary
160
+            ),
152161
         )
153162
 
154163
 
@@ -190,6 +199,7 @@ class WorkflowTimelineEntry:
190199
     runner_up_score: float | None = None
191200
     scheduled_next_mode: str | None = None
192201
     unresolved_questions: list[str] = field(default_factory=list)
202
+    signal_summary: list[str] = field(default_factory=list)
193203
     prompt_format: str | None = None
194204
     prompt_sections: list[str] = field(default_factory=list)
195205
     artifact_paths: list[str] = field(default_factory=list)
@@ -207,6 +217,7 @@ class WorkflowTimelineEntry:
207217
             "runner_up_score": self.runner_up_score,
208218
             "scheduled_next_mode": self.scheduled_next_mode,
209219
             "unresolved_questions": list(self.unresolved_questions),
220
+            "signal_summary": list(self.signal_summary),
210221
             "prompt_format": self.prompt_format,
211222
             "prompt_sections": list(self.prompt_sections),
212223
             "artifact_paths": list(self.artifact_paths),
@@ -226,6 +237,7 @@ class WorkflowTimelineEntry:
226237
             runner_up_score=_optional_float(data.get("runner_up_score")),
227238
             scheduled_next_mode=_optional_text(data.get("scheduled_next_mode")),
228239
             unresolved_questions=_string_list(data.get("unresolved_questions")),
240
+            signal_summary=_string_list(data.get("signal_summary")),
229241
             prompt_format=_optional_text(data.get("prompt_format")),
230242
             prompt_sections=_string_list(data.get("prompt_sections")),
231243
             artifact_paths=_string_list(data.get("artifact_paths")),
@@ -262,6 +274,7 @@ class WorkflowTimelineEntry:
262274
                 else None
263275
             ),
264276
             unresolved_questions=list(decision.unresolved_questions),
277
+            signal_summary=list(decision.signal_summary),
265278
             prompt_format=prompt_format,
266279
             prompt_sections=list(prompt_sections or []),
267280
             artifact_paths=list(artifact_paths or []),
@@ -274,6 +287,9 @@ class WorkflowPolicy:
274287
     clarify_threshold = 0.55
275288
     plan_threshold = 0.45
276289
 
290
+    def __init__(self, signal_extractor: WorkflowSignalExtractor | None = None) -> None:
291
+        self.signal_extractor = signal_extractor or WorkflowSignalExtractor()
292
+
277293
     def route(
278294
         self,
279295
         task: str,
@@ -286,8 +302,26 @@ class WorkflowPolicy:
286302
         mutating_history: bool = False,
287303
         stale_plan: bool = False,
288304
         unresolved_questions: list[str] | None = None,
305
+        timeline: list[WorkflowTimelineEntry] | None = None,
289306
     ) -> ModeDecision:
290
-        unresolved_questions = list(unresolved_questions or [])
307
+        signals = self.signal_extractor.extract_route_signals(
308
+            task,
309
+            requested_mode=requested_mode.value if requested_mode is not None else None,
310
+            has_brief=has_brief,
311
+            has_plan=has_plan,
312
+            allow_clarify=allow_clarify,
313
+            verification_pressure=verification_pressure,
314
+            mutating_history=mutating_history,
315
+            stale_plan=stale_plan,
316
+            unresolved_questions=unresolved_questions,
317
+            timeline=timeline,
318
+        )
319
+        return self.route_from_signals(signals)
320
+
321
+    def route_from_signals(self, signals: WorkflowSignalPacket) -> ModeDecision:
322
+        """Route from a typed workflow-signal packet."""
323
+
324
+        requested_mode = WorkflowMode.from_str(signals.requested_mode)
291325
         if requested_mode is not None:
292326
             return ModeDecision(
293327
                 mode=requested_mode,
@@ -300,9 +334,10 @@ class WorkflowPolicy:
300334
                     if requested_mode in {WorkflowMode.CLARIFY, WorkflowMode.PLAN}
301335
                     else None
302336
                 ),
337
+                signal_summary=list(signals.signal_summary),
303338
             )
304339
 
305
-        if stale_plan:
340
+        if signals.stale_artifact_pressure > 0:
306341
             return ModeDecision(
307342
                 mode=WorkflowMode.PLAN,
308343
                 reason_code="stale_plan_artifacts",
@@ -312,14 +347,15 @@ class WorkflowPolicy:
312347
                 runner_up_mode=WorkflowMode.EXECUTE,
313348
                 runner_up_score=0.6,
314349
                 scheduled_next_mode=WorkflowMode.EXECUTE,
315
-                unresolved_questions=unresolved_questions,
350
+                unresolved_questions=list(signals.unresolved_questions),
316351
                 pressure_summary=[
317352
                     "plan refresh pressure: stale artifacts require a refreshed plan",
318353
                     "execute pressure: continue directly with the stale artifacts",
319354
                 ],
355
+                signal_summary=list(signals.signal_summary),
320356
             )
321357
 
322
-        if has_plan:
358
+        if signals.has_plan:
323359
             return ModeDecision(
324360
                 mode=WorkflowMode.EXECUTE,
325361
                 reason_code="existing_plan_artifacts",
@@ -328,45 +364,52 @@ class WorkflowPolicy:
328364
                 route_score=0.9,
329365
                 runner_up_mode=WorkflowMode.PLAN,
330366
                 runner_up_score=0.45,
331
-                unresolved_questions=unresolved_questions,
367
+                unresolved_questions=list(signals.unresolved_questions),
332368
                 pressure_summary=[
333369
                     "execute pressure: persisted plan artifacts already exist",
334370
                     "plan pressure: a plan refresh is available but not required",
335371
                 ],
372
+                signal_summary=list(signals.signal_summary),
336373
             )
337374
 
338
-        ambiguity = self._ambiguity_score(task)
339
-        complexity = self._complexity_score(task)
375
+        ambiguity = signals.ambiguity_score
376
+        complexity = signals.complexity_score
340377
 
341378
         clarify_pressure = ambiguity
342
-        if allow_clarify and not has_brief:
379
+        if signals.allow_clarify and not signals.has_brief:
343380
             clarify_pressure += 0.15
344
-        if unresolved_questions:
345
-            clarify_pressure += 0.12
381
+        if signals.unresolved_questions:
382
+            clarify_pressure += min(0.12, 0.04 * len(signals.unresolved_questions))
346383
         if complexity < 0.55:
347384
             clarify_pressure += 0.05
348
-        if not allow_clarify:
385
+        if signals.recent_clarify_count and signals.unresolved_questions:
386
+            clarify_pressure += 0.04
387
+        if not signals.allow_clarify:
349388
             clarify_pressure = 0.0
350389
 
351390
         plan_pressure = complexity
352
-        if verification_pressure:
353
-            plan_pressure += 0.12
354
-        if mutating_history:
355
-            plan_pressure += 0.08
356
-        if has_brief:
391
+        plan_pressure += signals.verification_pressure
392
+        plan_pressure += signals.mutation_pressure
393
+        if signals.has_brief:
357394
             plan_pressure += 0.06
358
-        if unresolved_questions:
395
+        if signals.unresolved_questions:
359396
             plan_pressure += 0.06
397
+        if signals.recent_reentry_count:
398
+            plan_pressure += 0.06
399
+        if signals.recent_plan_refresh_count:
400
+            plan_pressure += 0.04
360401
 
361402
         execute_pressure = 0.35
362
-        if has_brief:
403
+        if signals.has_brief:
363404
             execute_pressure += 0.14
364405
         if ambiguity < 0.35:
365406
             execute_pressure += 0.16
366407
         if complexity < 0.45:
367408
             execute_pressure += 0.12
368
-        if not unresolved_questions:
409
+        if not signals.unresolved_questions:
369410
             execute_pressure += 0.05
411
+        if signals.recent_verify_skip_count and not signals.verification_pressure:
412
+            execute_pressure += 0.03
370413
 
371414
         scores = {
372415
             WorkflowMode.CLARIFY: round(min(clarify_pressure, 1.0), 3),
@@ -385,7 +428,7 @@ class WorkflowPolicy:
385428
         if (
386429
             winner == WorkflowMode.CLARIFY
387430
             and winner_score >= self.clarify_threshold
388
-            and allow_clarify
431
+            and signals.allow_clarify
389432
         ):
390433
             return ModeDecision(
391434
                 mode=WorkflowMode.CLARIFY,
@@ -397,19 +440,20 @@ class WorkflowPolicy:
397440
                 runner_up_mode=runner_up,
398441
                 runner_up_score=runner_up_score,
399442
                 scheduled_next_mode=WorkflowMode.EXECUTE,
400
-                unresolved_questions=unresolved_questions,
443
+                unresolved_questions=list(signals.unresolved_questions),
401444
                 pressure_summary=pressure_summary,
445
+                signal_summary=list(signals.signal_summary),
402446
             )
403447
 
404448
         if winner == WorkflowMode.PLAN and winner_score >= self.plan_threshold:
405449
             reason_code = (
406450
                 "verification_pressure_requires_plan"
407
-                if verification_pressure
451
+                if signals.verification_pressure
408452
                 else "task_is_complex"
409453
             )
410454
             reason_summary = (
411455
                 "verification pressure and task complexity favor a persisted plan"
412
-                if verification_pressure
456
+                if signals.verification_pressure
413457
                 else "workflow pressure favors a persisted plan before execution"
414458
             )
415459
             return ModeDecision(
@@ -422,8 +466,9 @@ class WorkflowPolicy:
422466
                 runner_up_mode=runner_up,
423467
                 runner_up_score=runner_up_score,
424468
                 scheduled_next_mode=WorkflowMode.EXECUTE,
425
-                unresolved_questions=unresolved_questions,
469
+                unresolved_questions=list(signals.unresolved_questions),
426470
                 pressure_summary=pressure_summary,
471
+                signal_summary=list(signals.signal_summary),
427472
             )
428473
 
429474
         return ModeDecision(
@@ -435,8 +480,9 @@ class WorkflowPolicy:
435480
             route_score=winner_score,
436481
             runner_up_mode=runner_up,
437482
             runner_up_score=runner_up_score,
438
-            unresolved_questions=unresolved_questions,
483
+            unresolved_questions=list(signals.unresolved_questions),
439484
             pressure_summary=pressure_summary,
485
+            signal_summary=list(signals.signal_summary),
440486
         )
441487
 
442488
     def review_clarify(
src/loader/runtime/workflow_signals.pyadded
@@ -0,0 +1,221 @@
1
+"""Typed workflow-signal extraction for runtime policy decisions."""
2
+
3
+from __future__ import annotations
4
+
5
+import re
6
+from dataclasses import dataclass, field
7
+from typing import TYPE_CHECKING
8
+
9
+if TYPE_CHECKING:
10
+    from .workflow_policy import WorkflowTimelineEntry
11
+
12
+
13
+@dataclass(slots=True)
14
+class WorkflowSignalPacket:
15
+    """Typed route context consumed by workflow policy."""
16
+
17
+    task: str
18
+    requested_mode: str | None = None
19
+    has_brief: bool = False
20
+    has_plan: bool = False
21
+    allow_clarify: bool = True
22
+    ambiguity_score: float = 0.0
23
+    complexity_score: float = 0.0
24
+    verification_pressure: float = 0.0
25
+    mutation_pressure: float = 0.0
26
+    artifact_reuse_pressure: float = 0.0
27
+    stale_artifact_pressure: float = 0.0
28
+    unresolved_questions: list[str] = field(default_factory=list)
29
+    recent_clarify_count: int = 0
30
+    recent_reentry_count: int = 0
31
+    recent_plan_refresh_count: int = 0
32
+    recent_verify_skip_count: int = 0
33
+    signal_summary: list[str] = field(default_factory=list)
34
+
35
+
36
+class WorkflowSignalExtractor:
37
+    """Build typed workflow-signal packets from runtime state."""
38
+
39
+    def extract_route_signals(
40
+        self,
41
+        task: str,
42
+        *,
43
+        requested_mode: str | None = None,
44
+        has_brief: bool = False,
45
+        has_plan: bool = False,
46
+        allow_clarify: bool = True,
47
+        verification_pressure: bool = False,
48
+        mutating_history: bool = False,
49
+        stale_plan: bool = False,
50
+        unresolved_questions: list[str] | None = None,
51
+        timeline: list[WorkflowTimelineEntry] | None = None,
52
+    ) -> WorkflowSignalPacket:
53
+        """Derive workflow signals from task state and recent timeline context."""
54
+
55
+        unresolved_questions = list(unresolved_questions or [])
56
+        recent_timeline = list(timeline or [])[-6:]
57
+        recent_clarify_count = sum(
58
+            1
59
+            for entry in recent_timeline
60
+            if entry.mode == "clarify" or entry.kind.startswith("clarify")
61
+        )
62
+        recent_reentry_count = sum(
63
+            1
64
+            for entry in recent_timeline
65
+            if entry.kind == "reentry" or entry.decision_kind == "reentry"
66
+        )
67
+        recent_plan_refresh_count = sum(
68
+            1
69
+            for entry in recent_timeline
70
+            if entry.kind == "plan_refresh" or "plan_refresh" in entry.reason_code
71
+        )
72
+        recent_verify_skip_count = sum(
73
+            1
74
+            for entry in recent_timeline
75
+            if entry.kind == "verify_skip"
76
+        )
77
+        ambiguity_score = self._ambiguity_score(task)
78
+        complexity_score = self._complexity_score(task)
79
+        verification_signal = 0.18 if verification_pressure else 0.0
80
+        mutation_signal = 0.12 if mutating_history else 0.0
81
+        artifact_reuse_signal = 0.2 if has_plan else 0.0
82
+        stale_artifact_signal = 0.45 if stale_plan else 0.0
83
+
84
+        signal_summary: list[str] = [
85
+            f"ambiguity={ambiguity_score:.2f}",
86
+            f"complexity={complexity_score:.2f}",
87
+        ]
88
+        if requested_mode:
89
+            signal_summary.append(f"requested_mode={requested_mode}")
90
+        if has_brief:
91
+            signal_summary.append("clarify_brief=available")
92
+        if has_plan:
93
+            signal_summary.append("plan_artifacts=available")
94
+        if stale_plan:
95
+            signal_summary.append("plan_artifacts=stale")
96
+        if unresolved_questions:
97
+            signal_summary.append(
98
+                f"open_questions={min(len(unresolved_questions), 9)}"
99
+            )
100
+        if verification_pressure:
101
+            signal_summary.append("verification_pressure=active")
102
+        if mutating_history:
103
+            signal_summary.append("mutation_pressure=active")
104
+        if recent_clarify_count:
105
+            signal_summary.append(f"recent_clarify={recent_clarify_count}")
106
+        if recent_reentry_count:
107
+            signal_summary.append(f"recent_reentry={recent_reentry_count}")
108
+        if recent_plan_refresh_count:
109
+            signal_summary.append(f"recent_plan_refresh={recent_plan_refresh_count}")
110
+        if recent_verify_skip_count:
111
+            signal_summary.append(f"recent_verify_skip={recent_verify_skip_count}")
112
+
113
+        return WorkflowSignalPacket(
114
+            task=task,
115
+            requested_mode=requested_mode,
116
+            has_brief=has_brief,
117
+            has_plan=has_plan,
118
+            allow_clarify=allow_clarify,
119
+            ambiguity_score=ambiguity_score,
120
+            complexity_score=complexity_score,
121
+            verification_pressure=verification_signal,
122
+            mutation_pressure=mutation_signal,
123
+            artifact_reuse_pressure=artifact_reuse_signal,
124
+            stale_artifact_pressure=stale_artifact_signal,
125
+            unresolved_questions=unresolved_questions,
126
+            recent_clarify_count=recent_clarify_count,
127
+            recent_reentry_count=recent_reentry_count,
128
+            recent_plan_refresh_count=recent_plan_refresh_count,
129
+            recent_verify_skip_count=recent_verify_skip_count,
130
+            signal_summary=signal_summary,
131
+        )
132
+
133
+    @staticmethod
134
+    def _ambiguity_score(task: str) -> float:
135
+        lowered = task.lower()
136
+        words = re.findall(r"\w+", lowered)
137
+        score = 0.0
138
+
139
+        if (
140
+            "--clarify" in lowered
141
+            or "don't assume" in lowered
142
+            or "do not assume" in lowered
143
+            or "not sure" in lowered
144
+            or "figure out" in lowered
145
+            or "interview me" in lowered
146
+            or "ask me" in lowered
147
+            or lowered.startswith("clarify ")
148
+        ):
149
+            score += 0.65
150
+
151
+        if any(
152
+            phrase in lowered
153
+            for phrase in (
154
+                "something",
155
+                "somehow",
156
+                "better",
157
+                "improve",
158
+                "fix this",
159
+                "make it",
160
+                "more like",
161
+                "feels more like",
162
+            )
163
+        ):
164
+            score += 0.2
165
+
166
+        if not _has_concrete_anchor(task):
167
+            score += 0.2
168
+
169
+        if len(words) <= 12 and any(
170
+            verb in lowered
171
+            for verb in ("build", "add", "improve", "refactor", "implement")
172
+        ):
173
+            score += 0.15
174
+
175
+        return round(min(score, 1.0), 3)
176
+
177
+    @staticmethod
178
+    def _complexity_score(task: str) -> float:
179
+        lowered = task.lower()
180
+        words = re.findall(r"\w+", lowered)
181
+        score = 0.0
182
+
183
+        if len(words) >= 18:
184
+            score += 0.2
185
+        if len(words) >= 30:
186
+            score += 0.15
187
+
188
+        if any(
189
+            phrase in lowered
190
+            for phrase in (
191
+                "refactor",
192
+                "architecture",
193
+                "migrate",
194
+                "persistent",
195
+                "workflow",
196
+                "deep dive",
197
+                "report",
198
+                "implementation plan",
199
+                "verification plan",
200
+            )
201
+        ):
202
+            score += 0.3
203
+
204
+        if lowered.count(" and ") >= 2 or lowered.count(",") >= 2:
205
+            score += 0.15
206
+
207
+        if _has_concrete_anchor(task):
208
+            score += 0.1
209
+
210
+        return round(min(score, 1.0), 3)
211
+
212
+
213
+def _has_concrete_anchor(task: str) -> bool:
214
+    return bool(
215
+        re.search(r"[./_\\-]", task)
216
+        or re.search(r"`[^`]+`", task)
217
+        or any(
218
+            token in task.lower()
219
+            for token in ("test", "file", "function", "class")
220
+        )
221
+    )
tests/test_workflow_policy.pymodified
@@ -8,6 +8,7 @@ from loader.runtime.workflow import (
88
     WorkflowTimelineEntry,
99
     WorkflowTimelineEntryKind,
1010
 )
11
+from loader.runtime.workflow_signals import WorkflowSignalPacket
1112
 
1213
 
1314
 def test_workflow_policy_reports_winner_and_runner_up() -> None:
@@ -20,6 +21,24 @@ def test_workflow_policy_reports_winner_and_runner_up() -> None:
2021
     assert decision.runner_up_mode is not None
2122
     assert decision.runner_up_score > 0
2223
     assert decision.pressure_summary
24
+    assert decision.signal_summary
25
+
26
+
27
+def test_workflow_policy_routes_from_typed_signal_packet() -> None:
28
+    policy = WorkflowPolicy()
29
+
30
+    decision = policy.route_from_signals(
31
+        WorkflowSignalPacket(
32
+            task="Keep improving Loader.",
33
+            ambiguity_score=0.62,
34
+            complexity_score=0.28,
35
+            allow_clarify=True,
36
+            signal_summary=["ambiguity=0.62", "complexity=0.28"],
37
+        )
38
+    )
39
+
40
+    assert decision.mode == WorkflowMode.CLARIFY
41
+    assert decision.signal_summary == ["ambiguity=0.62", "complexity=0.28"]
2342
 
2443
 
2544
 def test_workflow_policy_prefers_plan_refresh_for_stale_plan() -> None:
@@ -79,6 +98,7 @@ def test_workflow_timeline_entry_round_trips() -> None:
7998
         runner_up_score=0.66,
8099
         scheduled_next_mode="execute",
81100
         unresolved_questions=["Scope is still broad."],
101
+        signal_summary=["ambiguity=0.20", "complexity=0.81"],
82102
         prompt_format="native",
83103
         prompt_sections=["Runtime Config", "Workflow Context"],
84104
         artifact_paths=["/tmp/implementation.md"],
tests/test_workflow_signals.pyadded
@@ -0,0 +1,55 @@
1
+"""Tests for typed workflow-signal extraction."""
2
+
3
+from __future__ import annotations
4
+
5
+from loader.runtime.workflow import (
6
+    WorkflowSignalExtractor,
7
+    WorkflowTimelineEntry,
8
+)
9
+
10
+
11
+def test_workflow_signal_extractor_captures_recent_timeline_pressure() -> None:
12
+    extractor = WorkflowSignalExtractor()
13
+    timeline = [
14
+        WorkflowTimelineEntry(
15
+            timestamp="2026-04-07T12:00:00Z",
16
+            kind="clarify_continue",
17
+            mode="clarify",
18
+            reason_code="clarify_follow_up_needed",
19
+            summary="clarify: clarify pressure remains high",
20
+            decision_kind="forced",
21
+        ),
22
+        WorkflowTimelineEntry(
23
+            timestamp="2026-04-07T12:01:00Z",
24
+            kind="reentry",
25
+            mode="execute",
26
+            reason_code="verification_failed_reentry",
27
+            summary="execute: verification failed; returning to execute",
28
+            decision_kind="reentry",
29
+        ),
30
+        WorkflowTimelineEntry(
31
+            timestamp="2026-04-07T12:02:00Z",
32
+            kind="verify_skip",
33
+            mode="verify",
34
+            reason_code="verification_not_required",
35
+            summary="verify: verification skipped",
36
+            decision_kind="forced",
37
+        ),
38
+    ]
39
+
40
+    signals = extractor.extract_route_signals(
41
+        "Improve Loader so it feels more like claw-code.",
42
+        has_brief=True,
43
+        unresolved_questions=["Scope is still broad."],
44
+        timeline=timeline,
45
+    )
46
+
47
+    assert signals.ambiguity_score > 0
48
+    assert signals.has_brief is True
49
+    assert signals.recent_clarify_count == 1
50
+    assert signals.recent_reentry_count == 1
51
+    assert signals.recent_verify_skip_count == 1
52
+    assert "clarify_brief=available" in signals.signal_summary
53
+    assert "open_questions=1" in signals.signal_summary
54
+    assert "recent_reentry=1" in signals.signal_summary
55
+