@@ -80,6 +80,35 @@ def _build_fallback_impl(character_list: list[dict], chapter_list: list[dict]) -
8080 {"chapter" : vuln_ch_2 , "scene" : f"{ name } shows vulnerability through action — admits fault, asks for help, or breaks composure." },
8181 ]
8282
83+ # Plant 2-3 foreshadow beats per major pivot in earlier chapters
84+ # so each arc turn (midpoint, crisis, final) feels earned.
85+ # Foreshadow chapters are clamped to land BEFORE the pivot they
86+ # telegraph, and at least chapter 2 to leave room for setup.
87+ def _foreshadow_chapters (pivot_ch : int ) -> list [int ]:
88+ slots = [
89+ max (2 , pivot_ch - max (1 , pivot_ch // 3 )),
90+ max (2 , pivot_ch - max (1 , pivot_ch // 2 )),
91+ ]
92+ return sorted ({c for c in slots if c < pivot_ch })
93+
94+ foreshadow_beats : list [dict ] = []
95+ for pivot_label , pivot_ch in (
96+ ("midpoint" , midpoint_chapter ),
97+ ("crisis" , crisis_chapter ),
98+ ("final" , final_chapter ),
99+ ):
100+ for fch in _foreshadow_chapters (pivot_ch ):
101+ foreshadow_beats .append ({
102+ "chapter" : fch ,
103+ "foreshadows" : pivot_label ,
104+ "beat" : (
105+ f"Plant a small, dramatized moment for { name } that "
106+ f"telegraphs the upcoming { pivot_label } — a line of "
107+ f"dialogue, a decision, or a visible reaction that "
108+ f"the reader will recognize in retrospect."
109+ ),
110+ })
111+
83112 arcs .append (
84113 {
85114 "character" : name ,
@@ -110,6 +139,7 @@ def _build_fallback_impl(character_list: list[dict], chapter_list: list[dict]) -
110139 },
111140 ],
112141 "vulnerability_scenes" : vulnerability_scenes ,
142+ "foreshadow_beats" : foreshadow_beats ,
113143 "consistency_rules" : [
114144 "Arc must move forward each appearance." ,
115145 "No regression to start state after midpoint without explicit cause." ,
@@ -187,6 +217,31 @@ def normalise(self, data: dict, **ctx) -> dict:
187217 "scene" : str (vs .get ("scene" , "" )).strip (),
188218 })
189219
220+ # Foreshadow beats — scheduled plants for upcoming pivots. Each
221+ # beat binds a chapter to a specific pivot it telegraphs; the
222+ # per-chapter Character agent verifies the beat is on-page when
223+ # the scheduled chapter is written. Records missing a non-empty
224+ # ``beat`` field are dropped (they cannot be verified).
225+ foreshadow_beats = item .get ("foreshadow_beats" , [])
226+ if not isinstance (foreshadow_beats , list ):
227+ foreshadow_beats = []
228+ normalised_foreshadow : list [dict ] = []
229+ allowed_pivots = {"midpoint" , "crisis" , "final" }
230+ for fb in foreshadow_beats :
231+ if not isinstance (fb , dict ):
232+ continue
233+ beat_text = str (fb .get ("beat" , "" )).strip ()
234+ if not beat_text :
235+ continue
236+ foreshadows = str (fb .get ("foreshadows" , "" )).strip ().lower ()
237+ if foreshadows not in allowed_pivots :
238+ foreshadows = "midpoint"
239+ normalised_foreshadow .append ({
240+ "chapter" : _coerce_positive_int (fb .get ("chapter" ), 1 ),
241+ "foreshadows" : foreshadows ,
242+ "beat" : beat_text ,
243+ })
244+
190245 normalised_arcs .append (
191246 {
192247 "character" : name ,
@@ -200,6 +255,7 @@ def normalise(self, data: dict, **ctx) -> dict:
200255 "arc_theme" : str (item .get ("arc_theme" , "" )).strip (),
201256 "chapter_beats" : normalised_beats ,
202257 "vulnerability_scenes" : normalised_vuln ,
258+ "foreshadow_beats" : normalised_foreshadow ,
203259 "consistency_rules" : [str (x ) for x in consistency_rules if str (x ).strip ()],
204260 }
205261 )
@@ -263,7 +319,15 @@ def get_chapter_context(self, plan: dict, chapter_num: int) -> str:
263319 if isinstance (vs , dict ) and _coerce_positive_int (vs .get ("chapter" ), 0 ) == chapter_num
264320 ]
265321
266- if matching_beats or matching_vuln :
322+ foreshadow_beats = arc .get ("foreshadow_beats" , [])
323+ if not isinstance (foreshadow_beats , list ):
324+ foreshadow_beats = []
325+ matching_foreshadow = [
326+ fb for fb in foreshadow_beats
327+ if isinstance (fb , dict ) and _coerce_positive_int (fb .get ("chapter" ), 0 ) == chapter_num
328+ ]
329+
330+ if matching_beats or matching_vuln or matching_foreshadow :
267331 lines .append (
268332 f"- { char_name } : start={ arc .get ('start_state' , '' )} ; midpoint={ arc .get ('midpoint_transformation' , '' )} ; "
269333 f"crisis={ arc .get ('crisis_point' , '' )} ; final_choice={ arc .get ('final_moral_choice' , '' )} "
@@ -281,6 +345,13 @@ def get_chapter_context(self, plan: dict, chapter_num: int) -> str:
281345 f" - *** VULNERABILITY SCENE (this chapter): { vs .get ('scene' , '' )} — "
282346 f"Show this through ACTION and DIALOGUE, not narration. ***"
283347 )
348+ for fb in matching_foreshadow :
349+ pivot = str (fb .get ("foreshadows" , "" )).strip () or "upcoming pivot"
350+ lines .append (
351+ f" - *** FORESHADOW BEAT (this chapter, telegraphs { pivot } ): "
352+ f"{ fb .get ('beat' , '' )} — Must appear on-page through a "
353+ f"dramatized moment (dialogue, decision, or visible reaction). ***"
354+ )
284355 rules = arc .get ("consistency_rules" , [])
285356 if isinstance (rules , list ) and rules :
286357 lines .append (" - Arc rules: " + "; " .join (str (x ) for x in rules [:4 ]))
0 commit comments