next_regen.py file-level

at sha256:a · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:8 docs: queue board-identity follow-ups so they survive the session Capt… · aaronrene · Sep 5, 2026
1 """NEXT SESSION + paste-ready regeneration for governance-sync (§GSP)."""
2
3 from __future__ import annotations
4
5 import re
6 from dataclasses import dataclass
7 from pathlib import Path
8
9 import yaml
10
11 from adapters.config import OverseerConfig
12 from cli.docs_paths import join_docs_rel
13 from tools.governance_hygiene.parse import normalize_status, parse_queue_rows, phase_tokens
14 from tools.governance_hygiene.types import QueueRow
15
16 # Closed vocabulary from policy/model-labels.yaml ``labels[].display`` (frozen for GS-PASTE).
17 MODEL_DISPLAY_LABELS = frozenset(
18 {
19 "Thinking",
20 "Auto",
21 "Thinking → Auto",
22 "Operator + Auto",
23 }
24 )
25
26 SPLIT_MODEL = "Thinking → Auto"
27 REASON_ZERO = "zero_open_rows"
28 REASON_MULTIPLE = "multiple_open_rows"
29 REASON_INVALID_MODEL = "invalid_model_label"
30 REASON_SPLIT = "split_undetermined"
31 REASON_WORKSPACE_MARKER = "workspace_marker_absent"
32 REASON_LAND_A_WAIT = "land_a_in_progress"
33 REASON_LAND_PHASE_UNREADABLE = "land_phase_unreadable"
34
35 OPEN_STATUSES = frozenset({"TODO", "NEXT", "WIP"})
36 DONE_STATUSES = frozenset({"DONE", "MERGED"})
37
38 NEXT_MARKER_RE = re.compile(
39 r"<!--\s*overseer:next\s+role=[^>]+-->",
40 re.IGNORECASE,
41 )
42
43 # --- PMHF land protocol (§PMHF.3 / §PMHF.4) ---
44
45 LAND_PHASE_A = "land-a"
46 LAND_PHASE_B = "land-b"
47 LAND_PHASE_UNREADABLE = "unreadable"
48
49 # §PMHF.4.2 closed vocabulary — legacy handovers without the marker attribute.
50 # Frozen: bare "open PR" / "Tier 3" alone must NOT count as land-a.
51 LAND_A_VOCABULARY = (
52 "land-phase: land-a",
53 "wait for merge",
54 "awaiting merge",
55 "stop for tier 3 merge",
56 "→ main (land-a)",
57 "(land-a)",
58 )
59 LAND_B_VOCABULARY = (
60 "land-phase: land-b",
61 "land-b (post-merge sync)",
62 )
63
64 # §PMHF.5.2 step 7 frozen remediation string (prefix).
65 LAND_B_REMEDIATION = (
66 "land-b required: ok governance-sync --dry-run then apply; "
67 "paste land-b; do not re-paste land-a"
68 )
69
70 _LAND_PHASE_ATTR_RE = re.compile(r"\bland-phase=([^\s>]+)", re.IGNORECASE)
71 _PASTE_FENCE_RE = re.compile(
72 r"### Paste-ready prompt[^\n]*\n+```[a-zA-Z]*\n([\s\S]*?)```",
73 )
74 _LAND_ID_LINE_RE = re.compile(r"^\s*ID:\s*(.+?)\s*$", re.MULTILINE | re.IGNORECASE)
75 _NEXT_ID_CELL_RE = re.compile(r"\|\s*\*\*ID\*\*\s*\|\s*(.+?)\s*\|", re.IGNORECASE)
76 _LAND_PARENTHETICAL_RE = re.compile(r"\s*\((?:land-[ab])\)\s*$", re.IGNORECASE)
77 _PASTE_PR_RE = re.compile(r"\bPR #(\d+)\b")
78
79 # Tokens too generic to identify a land slice (§PMHF.3.3 rejected match rule).
80 _GENERIC_LAND_TOKENS = frozenset({"", "→", "->"})
81
82
83 def extract_paste_fence_body(handover_text: str) -> str | None:
84 """Return the paste-ready fence body, or None when no fence exists (§PMHF.4.2)."""
85 match = _PASTE_FENCE_RE.search(handover_text)
86 return match.group(1) if match else None
87
88
89 def resolve_land_phase(handover_text: str) -> str | None:
90 """Resolve land posture per §PMHF.4.
91
92 Returns ``"land-a"`` | ``"land-b"`` | ``None`` (no land posture) |
93 ``"unreadable"`` (unknown attribute value or conflicting vocabulary).
94 The marker HTML attribute beats vocabulary fallback when present.
95 """
96 marker_match = NEXT_MARKER_RE.search(handover_text)
97 if marker_match:
98 attr = _LAND_PHASE_ATTR_RE.search(marker_match.group(0))
99 if attr:
100 value = attr.group(1).strip().lower()
101 if value in {LAND_PHASE_A, LAND_PHASE_B}:
102 return value
103 return LAND_PHASE_UNREADABLE
104
105 body = extract_paste_fence_body(handover_text)
106 if body is None:
107 return None
108 lowered = body.lower()
109 is_land_a = any(token in lowered for token in LAND_A_VOCABULARY)
110 is_land_b = any(token in lowered for token in LAND_B_VOCABULARY)
111 if is_land_a and is_land_b:
112 return LAND_PHASE_UNREADABLE
113 if is_land_a:
114 return LAND_PHASE_A
115 if is_land_b:
116 return LAND_PHASE_B
117 return None
118
119
120 def extract_land_id(handover_text: str) -> str | None:
121 """Land ID from paste ``ID:`` line, falling back to the NEXT ``| **ID** |`` cell."""
122 body = extract_paste_fence_body(handover_text)
123 if body is not None:
124 match = _LAND_ID_LINE_RE.search(body)
125 if match:
126 return match.group(1).strip()
127 cell = _NEXT_ID_CELL_RE.search(handover_text)
128 if cell:
129 return cell.group(1).strip().strip("*").strip()
130 return None
131
132
133 def strip_land_parenthetical(land_id: str) -> str:
134 """Strip a trailing ``(land-a)`` / ``(land-b)`` so tokens align with queue rows (§PMHF.3.3)."""
135 return _LAND_PARENTHETICAL_RE.sub("", land_id).strip()
136
137
138 def _meaningful_land_tokens(label: str, main_branch: str) -> set[str]:
139 generic = set(_GENERIC_LAND_TOKENS) | {main_branch.lower()}
140 return {token.lower() for token in phase_tokens(label)} - generic
141
142
143 def land_queue_conflict(
144 roadmap_text: str,
145 land_id: str,
146 *,
147 main_branch: str = "main",
148 ) -> bool:
149 """§PMHF.3.3: DONE/MERGED **land** queue row matching the current land-a ID.
150
151 A candidate row must be land-shaped (``{slice} → main``) and share a
152 slice-identifying token with the land-a ID (never just ``→ main`` — the
153 frozen rejected match rule protects historical other-slice land rows).
154
155 Hyphen-split fragments alone are not slice-identifying: land-a
156 ``GSW-FIX → main`` must not conflict with historical ``GFG-D2-FIX → main``
157 solely on the fragment ``FIX``.
158 """
159 id_tokens = _meaningful_land_tokens(strip_land_parenthetical(land_id), main_branch)
160 if not id_tokens:
161 return False
162 land_row_re = re.compile(r"(?:→|->)\s*" + re.escape(main_branch), re.IGNORECASE)
163 for row in parse_queue_rows(roadmap_text):
164 if normalize_status(row.status) not in DONE_STATUSES:
165 continue
166 if not land_row_re.search(row.phase_label):
167 continue
168 shared = id_tokens & _meaningful_land_tokens(row.phase_label, main_branch)
169 if shared and not _only_hyphen_fragments(shared, id_tokens):
170 return True
171 return False
172
173
174 def _only_hyphen_fragments(shared: set[str], id_tokens: set[str]) -> bool:
175 """True when every shared token is only a hyphen/space fragment of a longer id token.
176
177 Compound id tokens (containing ``-`` or an arrow) supply the fragment set.
178 A shared token that is itself compound is never treated as a bare fragment.
179 """
180 compounds = {token for token in id_tokens if "-" in token or "→" in token or "->" in token}
181 if not compounds:
182 return False
183 fragment_parts: set[str] = set()
184 for compound in compounds:
185 for part in re.split(r"[\s/—\-→>]+", compound):
186 cleaned = part.strip().lower()
187 if cleaned and cleaned not in _GENERIC_LAND_TOKENS:
188 fragment_parts.add(cleaned)
189 for token in shared:
190 if token in compounds or "-" in token or "→" in token or "->" in token:
191 return False
192 if token not in fragment_parts:
193 return False
194 return True
195
196
197 def extract_paste_pr_number(handover_text: str) -> int | None:
198 """First ``PR #<digits>`` named in the paste-ready fence (§PMHF.5.3)."""
199 body = extract_paste_fence_body(handover_text)
200 if body is None:
201 return None
202 match = _PASTE_PR_RE.search(body)
203 return int(match.group(1)) if match else None
204
205
206 def set_marker_land_phase(handover_text: str, land_phase: str | None) -> str:
207 """Set or clear the ``land-phase=`` attribute on the existing NEXT marker (§PMHF.4.1)."""
208 match = NEXT_MARKER_RE.search(handover_text)
209 if not match:
210 return handover_text
211 marker = match.group(0)
212 updated = re.sub(r"\s+land-phase=[^\s>]+", "", marker, flags=re.IGNORECASE)
213 if land_phase:
214 updated = re.sub(r"\s*-->$", f" land-phase={land_phase} -->", updated)
215 return handover_text.replace(marker, updated, 1)
216
217
218 @dataclass(frozen=True)
219 class LandBPlan:
220 """Planned land-b emission for the slice currently mid-land (§PMHF.3.4)."""
221
222 slice_id: str
223 land_a_id: str
224
225
226 def _slice_from_land_id(land_id: str, main_branch: str) -> str:
227 base = strip_land_parenthetical(land_id)
228 base = re.sub(
229 r"\s*(?:→|->)\s*" + re.escape(main_branch) + r"\s*$",
230 "",
231 base,
232 flags=re.IGNORECASE,
233 )
234 return base.strip()
235
236
237 def plan_land_b(
238 handover_text: str,
239 *,
240 d1: str | None,
241 freshness_state: str | None = None,
242 merged_pr_signal: bool = False,
243 main_branch: str = "main",
244 ) -> LandBPlan | None:
245 """Return a land-b plan when NEXT is land-a and closeout is post-merge incomplete.
246
247 §PMHF.3.4 rule 1: emit land-b for the same slice — never re-emit land-a.
248 """
249 if resolve_land_phase(handover_text) != LAND_PHASE_A:
250 return None
251 post_merge_incomplete = (
252 d1 == "drifted"
253 or freshness_state in {"drifted", "stale_marker"}
254 or merged_pr_signal
255 )
256 if not post_merge_incomplete:
257 return None
258 land_id = extract_land_id(handover_text) or ""
259 slice_id = _slice_from_land_id(land_id, main_branch) if land_id else ""
260 return LandBPlan(slice_id=slice_id or "land", land_a_id=land_id)
261
262
263 def render_land_b_next_session(
264 plan: LandBPlan,
265 *,
266 config: OverseerConfig,
267 sync_date: str,
268 ) -> str:
269 """Render the land-b NEXT SESSION body (frozen shape §PMHF.3.2)."""
270 main = config.vcs.git.main_branch or "main"
271 lines = [
272 f"## NEXT SESSION — {plan.slice_id} land-b (post-merge sync)",
273 "",
274 f"**Date:** {sync_date} ",
275 f"**Current position:** {plan.slice_id} land-a → land-b ",
276 "**Model:** Auto",
277 "",
278 "### THE ONE NEXT STEP — **Model: Auto**",
279 "",
280 f"Post-merge governance closeout for **{plan.slice_id}**: sync ROADMAP + HANDOVER to "
281 f"merged `{main}` so NEXT and paste no longer point at the pre-merge posture.",
282 "",
283 "| | |",
284 "| --- | --- |",
285 f"| **ID** | **{plan.slice_id} land-b (post-merge sync)** |",
286 f"| **Repo** | **{config.repo.name}** |",
287 f"| **Read first** | {_docs_read_first(config)} |",
288 f"| **Hard stops** | no silent commits to `{main}`; no Cursor-only dependency; "
289 "no freeze/BV redesign |",
290 ]
291 return "\n".join(lines)
292
293
294 def render_land_b_paste(plan: LandBPlan, *, config: OverseerConfig) -> str:
295 """Render the frozen land-b paste (§PMHF.3.2) inside the paste-ready anchor."""
296 main = config.vcs.git.main_branch or "main"
297 fence_lines = [
298 "Model: Auto",
299 f"ID: {plan.slice_id} land-b (post-merge sync)",
300 "land-phase: land-b",
301 "",
302 "Deliver:",
303 f"1. Fetch/pull latest {main} (regime-appropriate)",
304 "2. ok governance-sync --dry-run then apply when the plan is correct",
305 "3. Regenerate NEXT + paste so they no longer say wait-for-merge / land-a",
306 "4. Feature-branch commit bundling ROADMAP + HANDOVER (SD-17); open docs PR if needed",
307 "5. ok status --exit-code → 0 and ok land-closeout → 0 before claiming land complete",
308 "",
309 f"Hard stops: no silent commits to {main}; no Cursor-only dependency; "
310 "no freeze/BV redesign.",
311 ]
312 return "\n".join(
313 [
314 f"### Paste-ready prompt — {plan.slice_id} land-b",
315 "",
316 "```text",
317 "\n".join(fence_lines),
318 "```",
319 ]
320 )
321
322 HARD_STOPS = (
323 "No merge to `main` without Tier 3 · no secrets · no live posture flips · "
324 "no inventing NEXT when ambiguous"
325 )
326
327 BUILD_VERIFICATION_LINES = (
328 "Governance gates (mandatory — remind only; silence is not pass):",
329 "- Freeze review: /freeze-review-loop before Thinking freeze → DONE; "
330 "ok review --freeze when CLI green",
331 "- Build verification: /build-verification-review after every Auto "
332 "{step}b before ROADMAP DONE",
333 )
334
335
336 @dataclass(frozen=True)
337 class NextRegenDecision:
338 """Outcome of unambiguous-NEXT selection + emission planning."""
339
340 row: QueueRow | None
341 reason: str | None
342 emit_model: str | None
343 step_label: str | None
344 is_step_b: bool
345
346
347 def normalize_model_cell(model: str) -> str:
348 """Strip surrounding markdown bold from a queue Model cell."""
349 text = model.strip()
350 if text.startswith("**") and text.endswith("**") and len(text) > 4:
351 text = text[2:-2].strip()
352 return text
353
354
355 def compact_step_id(phase_label: str) -> str:
356 """Primary phase id token (first whitespace-/ segment of bold label)."""
357 tokens = phase_tokens(phase_label)
358 if not tokens:
359 return phase_label.strip()
360 primary = tokens[0]
361 first = re.split(r"[\s/]+", primary, maxsplit=1)[0].strip()
362 return first or primary.strip()
363
364
365 def select_unambiguous_next_row(
366 roadmap_text: str,
367 ) -> tuple[QueueRow | None, str | None]:
368 """Return the sole open queue row, or ``(None, reason)`` when ambiguous.
369
370 Reasons: ``zero_open_rows`` | ``multiple_open_rows`` | ``invalid_model_label``.
371 Split detection is separate (§GSP.5.2) and may add ``split_undetermined``.
372 """
373 rows = parse_queue_rows(roadmap_text)
374 open_rows = [row for row in rows if normalize_status(row.status) in OPEN_STATUSES]
375 if len(open_rows) == 0:
376 return None, REASON_ZERO
377 if len(open_rows) > 1:
378 return None, REASON_MULTIPLE
379 row = open_rows[0]
380 model = normalize_model_cell(row.model)
381 if not model or model not in MODEL_DISPLAY_LABELS:
382 return None, REASON_INVALID_MODEL
383 return row, None
384
385
386 def last_done_row(roadmap_text: str) -> QueueRow | None:
387 """Last DONE/MERGED queue row by table order, or None."""
388 done: QueueRow | None = None
389 for row in parse_queue_rows(roadmap_text):
390 if normalize_status(row.status) in DONE_STATUSES:
391 done = row
392 return done
393
394
395 def _freeze_pass_state(path: Path) -> str:
396 """Return ``pass``, ``non_pass``, or ``absent`` for a freeze artifact."""
397 try:
398 text = path.read_text(encoding="utf-8")
399 except OSError:
400 return "absent"
401
402 # YAML fence blocks (primary): look for review_stamp.verdict
403 for match in re.finditer(r"```ya?ml\s*\n(.*?)```", text, flags=re.DOTALL | re.IGNORECASE):
404 try:
405 raw = yaml.safe_load(match.group(1))
406 except yaml.YAMLError:
407 continue
408 if not isinstance(raw, dict):
409 continue
410 stamp = raw.get("review_stamp")
411 if isinstance(stamp, dict) and "verdict" in stamp:
412 verdict = str(stamp.get("verdict", "")).strip().lower()
413 return "pass" if verdict == "pass" else "non_pass"
414
415 # Review record prose / table fallback
416 if re.search(r"verdict:\s*pass\b", text, flags=re.IGNORECASE):
417 return "pass"
418 if re.search(
419 r"\*\*`?pass`?\*\*|→\s*\*\*`?pass`?\*\*|reviewed\s*→\s*`?pass`?",
420 text,
421 flags=re.IGNORECASE,
422 ):
423 return "pass"
424 if re.search(r"review_stamp:[\s\S]*?verdict:\s*\S+", text, flags=re.IGNORECASE):
425 stamp_match = re.search(
426 r"review_stamp:[\s\S]*?verdict:\s*(\S+)",
427 text,
428 flags=re.IGNORECASE,
429 )
430 if stamp_match:
431 verdict = stamp_match.group(1).strip().strip("'\"`").lower()
432 if verdict == "pass":
433 return "pass"
434 if verdict:
435 return "non_pass"
436 return "absent"
437
438
439 def _path_under_docs(repo_root: Path, candidate: Path) -> Path | None:
440 """Return resolved path if it stays under ``repo_root/docs``; else None."""
441 docs = (repo_root / "docs").resolve()
442 try:
443 resolved = candidate.resolve()
444 resolved.relative_to(docs)
445 except (OSError, ValueError):
446 return None
447 if not resolved.is_file():
448 return None
449 return resolved
450
451
452 def discover_freeze_candidates(
453 repo_root: Path,
454 step_id: str,
455 deliverable: str,
456 ) -> list[Path]:
457 """Basename-only freeze discovery under ``docs/`` (§GSP.5.2 / §GSP.9)."""
458 docs = repo_root / "docs"
459 if not docs.is_dir():
460 return []
461
462 tokens: set[str] = set()
463 compact = step_id.strip()
464 if compact:
465 tokens.add(compact)
466 base = re.sub(r"-[ab]$", "", compact, flags=re.IGNORECASE)
467 if base:
468 tokens.add(base)
469
470 found: list[Path] = []
471 seen: set[Path] = set()
472 # Cap: only top-level docs/*.md names (no recursive walk).
473 for path in sorted(docs.glob("*.md")):
474 name_lower = path.name.lower()
475 if not name_lower.startswith("phase-"):
476 continue
477 for token in tokens:
478 if token.lower() in name_lower:
479 resolved = _path_under_docs(repo_root, path)
480 if resolved is not None and resolved not in seen:
481 seen.add(resolved)
482 found.append(resolved)
483 break
484
485 # Deliverable-cited path under docs/
486 for match in re.finditer(r"`?(docs/[^`\s|]+\.md)`?", deliverable):
487 rel = match.group(1)
488 if ".." in Path(rel).parts:
489 continue
490 candidate = repo_root / rel
491 resolved = _path_under_docs(repo_root, candidate)
492 if resolved is not None and resolved not in seen:
493 seen.add(resolved)
494 found.append(resolved)
495
496 return found
497
498
499 def decide_split_emission(
500 row: QueueRow,
501 repo_root: Path,
502 ) -> tuple[str | None, str | None, bool]:
503 """Return ``(emit_model, reason_or_None, is_step_b)`` for the open row.
504
505 For non-split labels, emit the queue model as a single prompt.
506 For ``Thinking → Auto``, emit a or b per freeze-pass detector.
507 """
508 model = normalize_model_cell(row.model)
509 if model != SPLIT_MODEL:
510 return model, None, False
511
512 step_id = compact_step_id(row.phase_label)
513 candidates = discover_freeze_candidates(repo_root, step_id, row.deliverable)
514 if not candidates:
515 return "Thinking", None, False
516
517 states = [_freeze_pass_state(path) for path in candidates]
518 has_pass = any(state == "pass" for state in states)
519 has_non_pass = any(state == "non_pass" for state in states)
520 if has_pass and has_non_pass:
521 return None, REASON_SPLIT, False
522 if has_pass and normalize_status(row.status) in OPEN_STATUSES:
523 return "Auto", None, True
524 return "Thinking", None, False
525
526
527 def check_workspace_next_marker(
528 handover_text: str,
529 config: OverseerConfig,
530 ) -> str | None:
531 """Return ambiguity reason when workspace is set and NEXT marker is absent."""
532 if config.workspace is None:
533 return None
534 if NEXT_MARKER_RE.search(handover_text):
535 return None
536 return REASON_WORKSPACE_MARKER
537
538
539 def plan_next_regen(
540 *,
541 roadmap_text: str,
542 handover_text: str,
543 config: OverseerConfig,
544 repo_root: Path,
545 ) -> NextRegenDecision:
546 """Full §GSP.4 + §GSP.5.2 + §GSP.5.4 decision for NEXT/paste regen."""
547 row, reason = select_unambiguous_next_row(roadmap_text)
548 if row is None:
549 return NextRegenDecision(None, reason, None, None, False)
550
551 marker_reason = check_workspace_next_marker(handover_text, config)
552 if marker_reason:
553 return NextRegenDecision(None, marker_reason, None, None, False)
554
555 emit_model, split_reason, is_step_b = decide_split_emission(row, repo_root)
556 if split_reason:
557 return NextRegenDecision(None, split_reason, None, None, False)
558
559 step_id = compact_step_id(row.phase_label)
560 if normalize_model_cell(row.model) == SPLIT_MODEL:
561 step_label = f"{re.sub(r'-[ab]$', '', step_id, flags=re.IGNORECASE)}-{'b' if is_step_b else 'a'}"
562 else:
563 step_label = step_id
564
565 return NextRegenDecision(row, None, emit_model, step_label, is_step_b)
566
567
568 def _branch_value(config: OverseerConfig, phase_id: str) -> str:
569 pattern = config.vcs.git.feature_branch_pattern
570 if not pattern or "{slug}" not in pattern:
571 return "`unknown`"
572 slug = re.sub(r"[^a-z0-9]+", "-", phase_id.lower()).strip("-")
573 if not slug:
574 return "`unknown`"
575 return f"`{pattern.replace('{slug}', slug)}`"
576
577
578 def _docs_read_first(config: OverseerConfig) -> str:
579 roadmap = join_docs_rel(config.repo.root_relative_docs, config.docs.roadmap)
580 handover = join_docs_rel(config.repo.root_relative_docs, config.docs.handover)
581 return f"`{roadmap}`; `{handover}`"
582
583
584 def _landed_table_row(roadmap_text: str) -> str:
585 done = last_done_row(roadmap_text)
586 if done is None:
587 return "| _(none)_ | Queue has no DONE/MERGED row yet |"
588 slice_id = compact_step_id(done.phase_label)
589 deliverable = done.deliverable.strip() or "_(no deliverable)_"
590 return f"| **{slice_id}** | {deliverable} |"
591
592
593 def _current_position(roadmap_text: str, open_row: QueueRow) -> str:
594 done = last_done_row(roadmap_text)
595 open_id = compact_step_id(open_row.phase_label)
596 if done is None:
597 return f"→ {open_id}"
598 return f"{compact_step_id(done.phase_label)} → {open_id}"
599
600
601 def ensure_primary_next_marker(handover_text: str, config: OverseerConfig) -> str:
602 """Insert PRIMARY next marker above NEXT SESSION when absent and allowed."""
603 if NEXT_MARKER_RE.search(handover_text):
604 return handover_text
605 if config.workspace is not None:
606 return handover_text
607 marker = "<!-- overseer:next role=primary lane=product status=live -->"
608 heading = "## NEXT SESSION"
609 if heading not in handover_text:
610 return handover_text
611 return handover_text.replace(heading, f"{marker}\n{heading}", 1)
612
613
614 def render_next_session(
615 *,
616 decision: NextRegenDecision,
617 roadmap_text: str,
618 config: OverseerConfig,
619 sync_date: str,
620 ) -> str:
621 """Render ``next-session`` anchor body (§GSP.5.3) — no paste fence."""
622 assert decision.row is not None
623 assert decision.emit_model is not None
624 assert decision.step_label is not None
625 row = decision.row
626 model = decision.emit_model
627 step_id = decision.step_label
628 title = phase_tokens(row.phase_label)[0] if phase_tokens(row.phase_label) else step_id
629 branch = _branch_value(config, step_id)
630 read_first = _docs_read_first(config)
631 position = _current_position(roadmap_text, row)
632 landed = _landed_table_row(roadmap_text)
633
634 lines = [
635 f"## NEXT SESSION — {title}",
636 "",
637 f"**Date:** {sync_date} ",
638 f"**Current position:** {position} ",
639 f"**Model:** {model}",
640 "",
641 "### What just landed",
642 "",
643 "| Slice | Deliverable |",
644 "| --- | --- |",
645 landed,
646 "",
647 f"### THE ONE NEXT STEP — **Model: {model}**",
648 "",
649 row.deliverable.strip() or f"Advance {step_id}.",
650 "",
651 "| | |",
652 "| --- | --- |",
653 f"| **ID** | **{step_id}** |",
654 f"| **Branch** | {branch} |",
655 f"| **Repo** | **{config.repo.name}** |",
656 f"| **Read first** | {read_first} |",
657 f"| **Hard stops** | {HARD_STOPS} |",
658 ]
659 return "\n".join(lines)
660
661
662 def render_paste_ready(
663 *,
664 decision: NextRegenDecision,
665 config: OverseerConfig,
666 ) -> str:
667 """Render ``paste-ready-prompt`` anchor body (§GSP.5.3 / H16)."""
668 assert decision.row is not None
669 assert decision.emit_model is not None
670 assert decision.step_label is not None
671 row = decision.row
672 model = decision.emit_model
673 step_id = decision.step_label
674 branch = _branch_value(config, step_id).strip("`")
675 read_first = _docs_read_first(config)
676 title = phase_tokens(row.phase_label)[0] if phase_tokens(row.phase_label) else step_id
677
678 fence_lines = [
679 f"{step_id} — {title} ({config.repo.name}).",
680 "",
681 f"Model: {model}",
682 f"Repo: {config.repo.name}",
683 f"Branch: {branch}",
684 f"Step: {step_id}",
685 "Authority: authoritative",
686 "",
687 f"Read first: {read_first}.",
688 "",
689 "Deliverables:",
690 f"- {row.deliverable.strip() or step_id}",
691 "",
692 f"Hard stops: {HARD_STOPS}",
693 "",
694 "Governance sync: update roadmap + handover on completion.",
695 ]
696 if model == "Auto" or decision.is_step_b:
697 fence_lines.append("")
698 fence_lines.extend(BUILD_VERIFICATION_LINES)
699
700 fence_body = "\n".join(fence_lines)
701 return "\n".join(
702 [
703 f"### Paste-ready prompt — {step_id}",
704 "",
705 "```text",
706 fence_body,
707 "```",
708 ]
709 )
710
711
712 def format_next_regen_token(decision: NextRegenDecision) -> str:
713 """Plan/result token for dry-run and change-log (§GSP.4.3 / §GSP.6.2)."""
714 if decision.row is not None and decision.reason is None:
715 return "next_regen: regenerated"
716 reason = decision.reason or "unknown"
717 return f"next_regen: human_authorship_required ({reason})"
718
719
720 def format_change_log_fragment(decision: NextRegenDecision) -> str:
721 """Additive change-log fragment (§GSP.6.2)."""
722 if decision.row is not None and decision.reason is None:
723 return "next_regen=regenerated"
724 reason = decision.reason or "unknown"
725 return f"next_regen=human_authorship_required:{reason}"