Pre-cleanup before extending intent classification. Extracts
``ChatRuntime._maybe_apply_discourse_planner(text, source_tag) ->
str | None`` and replaces the two duplicated blocks (cold-start
pack-grounded branch + warm post-walk branch) with single-line
``planned = ...; if planned is not None: assign`` call sites.
Signature locked: takes only the prompt and the already-classified
grounding source tag; returns the replacement surface or None.
Callers own assignment — the helper neither reads nor writes any
surface or articulation state. The warm site additionally does the
``articulation = replace(articulation, surface=planned)`` follow-up
which the cold site does not need.
Gating discipline unchanged (re-pinned in 9 new tests):
* Returns None when ``self.config.discourse_planner`` is False.
* Returns None unless source_tag ∈ {"pack", "teaching"}.
* Returns None when the classified intent has no subject.
* Returns None on single-move plans (BRIEF mode / empty bundle).
* Returns None on empty rendered string.
Behavior is byte-identical to the pre-dedup state — same metrics:
flag off: multi=0.1429, primed_multi=0.0000, conn=0.0769
flag on : multi=0.5238, primed_multi=0.5000, conn=0.2308
cognition eval byte-identical: public 100/100/91.7/100.
smoke suite 67/67.
The two paths now cannot drift; the upcoming intent classifier
extension lifts both branches in lockstep.