55This script owns everything that does NOT require judgement:
66 * the DONE-sentinel gate (self-termination),
77 * de-duplication state (so you are not pinged twice for the same email),
8- * ntfy notifications (high-confidence hit / maybe / backstop),
8+ * ntfy notifications (high-confidence hit / maybe / news heads-up / backstop),
99 * the hard July-15 backstop,
1010 * --reset (re-arm) and --dry-run (test) modes.
1111
@@ -139,6 +139,9 @@ def load_state() -> dict:
139139 # later be upgraded to a real hit if reclassified with higher confidence.
140140 data .setdefault ("high_confidence" , {})
141141 data .setdefault ("maybe" , {})
142+ # NEWS = substantive, non-actionable timeline/eligibility updates. Its own
143+ # de-dup axis so a heads-up never collides with the invite/maybe tracking.
144+ data .setdefault ("news" , {})
142145 return data
143146
144147
@@ -173,17 +176,22 @@ def _detail(c: dict) -> dict:
173176 }
174177
175178
176- def write_last_run (high : list , maybe : list , notice : str | None = None ) -> None :
179+ def write_last_run (high : list , maybe : list , news : list | None = None ,
180+ notice : str | None = None ) -> None :
177181 """Record THIS run's new alerts so the scheduled session can mirror them to
178182 Slack via the Slack MCP (the script can't reach the MCP itself). Overwritten
179183 every real run, so a stale file never causes a duplicate Slack ping.
184+
185+ `news` is the list of NEW timeline/eligibility heads-ups (NEWS tier); `notice`
186+ is the single backstop "window elapsed" string. Both ride along to Slack.
180187 """
181188 os .makedirs (STATE_DIR , exist_ok = True )
182189 payload = {
183190 "generated_at" : datetime .now ().astimezone ().isoformat (),
184191 "slack_user_id" : SLACK_USER_ID or None ,
185192 "high" : high ,
186193 "maybe" : maybe ,
194+ "news" : news or [],
187195 "notice" : notice ,
188196 }
189197 tmp = LAST_RUN_FILE + ".tmp"
@@ -281,6 +289,23 @@ def notify_maybe(c: dict, dry_run: bool) -> bool:
281289 )
282290
283291
292+ def notify_news (c : dict , dry_run : bool ) -> bool :
293+ body = (
294+ "R2 news — NOT an order invite, but a substantive update on when/whether "
295+ "you'll be able to order (e.g. a timeline or eligibility change).\n \n "
296+ + _details_block (c )
297+ + "\n \n (FYI only — not disarming; still watching for the actual invite.)"
298+ )
299+ return send_ntfy (
300+ title = "R2 timeline update - FYI" ,
301+ body = body ,
302+ priority = "default" ,
303+ tags = "calendar,car" ,
304+ click = gmail_link (c ["message_id" ]),
305+ dry_run = dry_run ,
306+ )
307+
308+
284309def notify_backstop (dry_run : bool ) -> bool :
285310 body = (
286311 "window elapsed — R2 invite never detected, disabling check.\n \n "
@@ -302,13 +327,18 @@ def notify_backstop(dry_run: bool) -> bool:
302327# ----------------------------------------------------------------------------
303328
304329def tier_of (c : dict ) -> str :
305- """Map a classification record to HIGH / MAYBE / NONE.
330+ """Map a classification record to HIGH / MAYBE / NEWS / NONE.
306331
307332 HIGH : ACTIONABLE_INVITE and confidence >= 0.7
308333 MAYBE: ACTIONABLE_INVITE and 0.4 <= confidence < 0.7
309334 (the classifier is instructed to place genuinely ambiguous, clearly
310335 Rivian-sent / order-related emails in this band so they surface as
311336 a MAYBE rather than being dropped)
337+ NEWS : TIMELINE_UPDATE — a substantive, NON-actionable update on when/whether
338+ I'll be able to order (a timeline, an order-window date, an
339+ acceleration/eligibility change). Worth a heads-up but never disarms;
340+ it is not the invite. Distinct from generic marketing/hype, which the
341+ classifier still labels MARKETING/NOISE and stays silent.
312342 NONE : everything else (silent)
313343 """
314344 classification = str (c .get ("classification" , "" )).strip ().upper ()
@@ -321,6 +351,9 @@ def tier_of(c: dict) -> str:
321351 return "HIGH"
322352 if conf >= MAYBE_LOW_THRESHOLD :
323353 return "MAYBE"
354+ return "NONE"
355+ if classification == "TIMELINE_UPDATE" :
356+ return "NEWS"
324357 return "NONE"
325358
326359
@@ -360,13 +393,13 @@ def _read_input(path: str | None) -> list:
360393 return data
361394
362395
363- def _print_slack_hint (high : list , maybe : list , notice : str | None ,
396+ def _print_slack_hint (high : list , maybe : list , news : list , notice : str | None ,
364397 dry_run : bool = False ) -> None :
365398 """Tell the operator (and the scheduled session) whether a Slack mirror is
366399 pending. The session reads state/last_run.json and sends via the Slack MCP."""
367- if not (high or maybe or notice ):
400+ if not (high or maybe or news or notice ):
368401 return
369- n = len (high ) + len (maybe ) + (1 if notice else 0 )
402+ n = len (high ) + len (maybe ) + len ( news ) + (1 if notice else 0 )
370403 where = "would record" if dry_run else "recorded"
371404 target = SLACK_USER_ID or "(SLACK_USER_ID unset — Slack mirror disabled)"
372405 print (f" [slack] { where } { n } alert(s) in state/last_run.json for Slack "
@@ -389,11 +422,11 @@ def cmd_process(input_path: str | None, dry_run: bool) -> int:
389422 if not dry_run :
390423 write_done ("backstop: window elapsed" )
391424 write_last_run ([], [], notice = notice )
392- _print_slack_hint ([], [], notice )
425+ _print_slack_hint ([], [], [], notice )
393426 print ("Backstop notice sent; monitor disarmed." )
394427 else :
395428 print (" [dry-run] would write DONE sentinel (backstop)." )
396- _print_slack_hint ([], [], notice , dry_run = True )
429+ _print_slack_hint ([], [], [], notice , dry_run = True )
397430 return 0
398431
399432 candidates = _read_input (input_path )
@@ -407,6 +440,7 @@ def cmd_process(input_path: str | None, dry_run: bool) -> int:
407440 did_high = False
408441 new_high : list = [] # new HIGH alerts this run (for the Slack mirror)
409442 new_maybe : list = [] # new MAYBE alerts this run
443+ new_news : list = [] # new NEWS (timeline/eligibility) heads-ups this run
410444
411445 for c in candidates :
412446 mid = c .get ("message_id" )
@@ -458,15 +492,31 @@ def cmd_process(input_path: str | None, dry_run: bool) -> int:
458492 "confidence" : c .get ("confidence" ),
459493 "notified_at" : datetime .now ().astimezone ().isoformat (),
460494 }
495+
496+ elif tier == "NEWS" :
497+ if mid in state ["news" ]:
498+ print (f" [dup ] { label } — already sent as a heads-up, skipping." )
499+ continue
500+ print (f" [NEWS] { label } " )
501+ new_news .append (_detail (c ))
502+ if notify_news (c , dry_run ):
503+ if not dry_run :
504+ state ["news" ][mid ] = {
505+ "subject" : c .get ("subject" ),
506+ "sender" : c .get ("sender" ),
507+ "received" : c .get ("received" ),
508+ "confidence" : c .get ("confidence" ),
509+ "notified_at" : datetime .now ().astimezone ().isoformat (),
510+ }
461511 else :
462512 print (f" [ ] { label } — not an invite (silent)." )
463513
464514 if not dry_run :
465515 save_state (state )
466- write_last_run (new_high , new_maybe )
467- _print_slack_hint (new_high , new_maybe , None )
516+ write_last_run (new_high , new_maybe , news = new_news )
517+ _print_slack_hint (new_high , new_maybe , new_news , None )
468518 else :
469- _print_slack_hint (new_high , new_maybe , None , dry_run = True )
519+ _print_slack_hint (new_high , new_maybe , new_news , None , dry_run = True )
470520
471521 # 3) Self-terminate on a confirmed, successfully-notified high-confidence hit.
472522 if did_high :
0 commit comments