next_regen.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:2 fix(muse): use rev-parse for muse-only branch status · · Sep 22, 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
10 from adapters.config import OverseerConfig
11 from cli.docs_paths import join_docs_rel
12 from tools.governance_hygiene.parse import (
13 compact_step_id,
14 normalize_status,
15 parse_queue_rows,
16 phase_tokens,
17 )
18 from tools.freeze_authorization.resolve import freeze_authorization_state
19 from tools.freeze_reviewer.artifact import (
20 artifact_digest,
21 extract_existing_stamp,
22 parse_artifact,
23 )
24 from tools.adversarial_freeze.authorize import (
25 adversarial_authorization_state,
26 aff_hold_bypassed,
27 author_loop_complete,
28 frv_authorizing_freeze_paths,
29 )
30 from tools.governance_hygiene.types import QueueRow
31
32 # Closed vocabulary from policy/model-labels.yaml ``labels[].display`` (frozen for GS-PASTE).
33 MODEL_DISPLAY_LABELS = frozenset(
34 {
35 "Thinking",
36 "Auto",
37 "Thinking → Auto",
38 "Operator + Auto",
39 }
40 )
41
42 SPLIT_MODEL = "Thinking → Auto"
43 REASON_ZERO = "zero_open_rows"
44 REASON_MULTIPLE = "multiple_open_rows"
45 REASON_INVALID_MODEL = "invalid_model_label"
46 REASON_SPLIT = "split_undetermined"
47 REASON_WORKSPACE_MARKER = "workspace_marker_absent"
48 REASON_LAND_A_WAIT = "land_a_in_progress"
49 REASON_LAND_PHASE_UNREADABLE = "land_phase_unreadable"
50 REASON_FREEZE_NOT_SUBSTANTIVE = "freeze_not_substantive"
51 ADVISORY_MECHANICAL_ONLY = "mechanical_only"
52 ADVISORY_OPERATOR_BLOCK = "operator_block"
53 ADVISORY_OPERATOR_BLOCK_MALFORMED = "operator_block_malformed"
54 ADVISORY_LEDGER_CHAIN_BROKEN = "ledger_chain_broken"
55 ADVISORY_FORGED_SUBSTANTIVE_GATE = "forged_substantive_gate"
56 ADVISORY_UNREADABLE_GATE = "unreadable_gate"
57 ADVISORY_ADVERSARIAL_FREEZE_PENDING = "adversarial_freeze_pending"
58 ADVISORY_ADVERSARIAL_FREEZE_SKIP = "adversarial_freeze_skip"
59 AUTO_MODEL = "Auto"
60 OPERATOR_AUTO_MODEL = "Operator + Auto"
61
62 OPEN_STATUSES = frozenset({"TODO", "NEXT", "WIP"})
63 DONE_STATUSES = frozenset({"DONE", "MERGED"})
64
65 NEXT_MARKER_RE = re.compile(
66 r"<!--\s*overseer:next\s+role=[^>]+-->",
67 re.IGNORECASE,
68 )
69
70 # --- PMHF land protocol (§PMHF.3 / §PMHF.4) ---
71
72 LAND_PHASE_A = "land-a"
73 LAND_PHASE_B = "land-b"
74 LAND_PHASE_UNREADABLE = "unreadable"
75
76 # §PMHF.4.2 closed vocabulary — legacy handovers without the marker attribute.
77 # Frozen: bare "open PR" / "Tier 3" alone must NOT count as land-a.
78 LAND_A_VOCABULARY = (
79 "land-phase: land-a",
80 "wait for merge",
81 "awaiting merge",
82 "stop for tier 3 merge",
83 "→ main (land-a)",
84 "(land-a)",
85 )
86 LAND_B_VOCABULARY = (
87 "land-phase: land-b",
88 "land-b (post-merge sync)",
89 )
90
91 # §PMHF.5.2 step 7 frozen remediation string (prefix).
92 LAND_B_REMEDIATION = (
93 "land-b required: ok governance-sync --dry-run then apply; "
94 "paste land-b; do not re-paste land-a"
95 )
96
97 _LAND_PHASE_ATTR_RE = re.compile(r"\bland-phase=([^\s>]+)", re.IGNORECASE)
98 _PASTE_FENCE_RE = re.compile(
99 r"### Paste-ready prompt[^\n]*\n+```[a-zA-Z]*\n([\s\S]*?)```",
100 )
101 _LAND_ID_LINE_RE = re.compile(r"^\s*ID:\s*(.+?)\s*$", re.MULTILINE | re.IGNORECASE)
102 _NEXT_ID_CELL_RE = re.compile(r"\|\s*\*\*ID\*\*\s*\|\s*(.+?)\s*\|", re.IGNORECASE)
103 _LAND_PARENTHETICAL_RE = re.compile(r"\s*\((?:land-[ab])\)\s*$", re.IGNORECASE)
104 _PASTE_PR_RE = re.compile(r"\bPR #(\d+)\b")
105
106 # Tokens too generic to identify a land slice (§PMHF.3.3 rejected match rule).
107 _GENERIC_LAND_TOKENS = frozenset({"", "→", "->"})
108
109
110 def extract_paste_fence_body(handover_text: str) -> str | None:
111 """Return the paste-ready fence body, or None when no fence exists (§PMHF.4.2)."""
112 match = _PASTE_FENCE_RE.search(handover_text)
113 return match.group(1) if match else None
114
115
116 def resolve_land_phase(handover_text: str) -> str | None:
117 """Resolve land posture per §PMHF.4.
118
119 Returns ``"land-a"`` | ``"land-b"`` | ``None`` (no land posture) |
120 ``"unreadable"`` (unknown attribute value or conflicting vocabulary).
121 The marker HTML attribute beats vocabulary fallback when present.
122 """
123 marker_match = NEXT_MARKER_RE.search(handover_text)
124 if marker_match:
125 attr = _LAND_PHASE_ATTR_RE.search(marker_match.group(0))
126 if attr:
127 value = attr.group(1).strip().lower()
128 if value in {LAND_PHASE_A, LAND_PHASE_B}:
129 return value
130 return LAND_PHASE_UNREADABLE
131
132 body = extract_paste_fence_body(handover_text)
133 if body is None:
134 return None
135 lowered = body.lower()
136 is_land_a = any(token in lowered for token in LAND_A_VOCABULARY)
137 is_land_b = any(token in lowered for token in LAND_B_VOCABULARY)
138 if is_land_a and is_land_b:
139 return LAND_PHASE_UNREADABLE
140 if is_land_a:
141 return LAND_PHASE_A
142 if is_land_b:
143 return LAND_PHASE_B
144 return None
145
146
147 def extract_land_id(handover_text: str) -> str | None:
148 """Land ID from paste ``ID:`` line, falling back to the NEXT ``| **ID** |`` cell."""
149 body = extract_paste_fence_body(handover_text)
150 if body is not None:
151 match = _LAND_ID_LINE_RE.search(body)
152 if match:
153 return match.group(1).strip()
154 cell = _NEXT_ID_CELL_RE.search(handover_text)
155 if cell:
156 return cell.group(1).strip().strip("*").strip()
157 return None
158
159
160 def strip_land_parenthetical(land_id: str) -> str:
161 """Strip a trailing ``(land-a)`` / ``(land-b)`` so tokens align with queue rows (§PMHF.3.3)."""
162 return _LAND_PARENTHETICAL_RE.sub("", land_id).strip()
163
164
165 def _meaningful_land_tokens(label: str, main_branch: str) -> set[str]:
166 generic = set(_GENERIC_LAND_TOKENS) | {main_branch.lower()}
167 return {token.lower() for token in phase_tokens(label)} - generic
168
169
170 def land_queue_conflict(
171 roadmap_text: str,
172 land_id: str,
173 *,
174 main_branch: str = "main",
175 ) -> bool:
176 """§PMHF.3.3: DONE/MERGED **land** queue row matching the current land-a ID.
177
178 A candidate row must be land-shaped (``{slice} → main``) and share a
179 slice-identifying token with the land-a ID (never just ``→ main`` — the
180 frozen rejected match rule protects historical other-slice land rows).
181
182 Hyphen-split fragments alone are not slice-identifying: land-a
183 ``GSW-FIX → main`` must not conflict with historical ``GFG-D2-FIX → main``
184 solely on the fragment ``FIX``.
185 """
186 id_tokens = _meaningful_land_tokens(strip_land_parenthetical(land_id), main_branch)
187 if not id_tokens:
188 return False
189 land_row_re = re.compile(r"(?:→|->)\s*" + re.escape(main_branch), re.IGNORECASE)
190 for row in parse_queue_rows(roadmap_text):
191 if normalize_status(row.status) not in DONE_STATUSES:
192 continue
193 if not land_row_re.search(row.phase_label):
194 continue
195 shared = id_tokens & _meaningful_land_tokens(row.phase_label, main_branch)
196 if shared and not _only_hyphen_fragments(shared, id_tokens):
197 return True
198 return False
199
200
201 def _only_hyphen_fragments(shared: set[str], id_tokens: set[str]) -> bool:
202 """True when every shared token is only a hyphen/space fragment of a longer id token.
203
204 Compound id tokens (containing ``-`` or an arrow) supply the fragment set.
205 A shared token that is itself compound is never treated as a bare fragment.
206 """
207 compounds = {token for token in id_tokens if "-" in token or "→" in token or "->" in token}
208 if not compounds:
209 return False
210 fragment_parts: set[str] = set()
211 for compound in compounds:
212 for part in re.split(r"[\s/—\-→>]+", compound):
213 cleaned = part.strip().lower()
214 if cleaned and cleaned not in _GENERIC_LAND_TOKENS:
215 fragment_parts.add(cleaned)
216 for token in shared:
217 if token in compounds or "-" in token or "→" in token or "->" in token:
218 return False
219 if token not in fragment_parts:
220 return False
221 return True
222
223
224 def extract_paste_pr_number(handover_text: str) -> int | None:
225 """First ``PR #<digits>`` named in the paste-ready fence (§PMHF.5.3)."""
226 body = extract_paste_fence_body(handover_text)
227 if body is None:
228 return None
229 match = _PASTE_PR_RE.search(body)
230 return int(match.group(1)) if match else None
231
232
233 def set_marker_land_phase(handover_text: str, land_phase: str | None) -> str:
234 """Set or clear the ``land-phase=`` attribute on the existing NEXT marker (§PMHF.4.1)."""
235 match = NEXT_MARKER_RE.search(handover_text)
236 if not match:
237 return handover_text
238 marker = match.group(0)
239 updated = re.sub(r"\s+land-phase=[^\s>]+", "", marker, flags=re.IGNORECASE)
240 if land_phase:
241 updated = re.sub(r"\s*-->$", f" land-phase={land_phase} -->", updated)
242 return handover_text.replace(marker, updated, 1)
243
244
245 @dataclass(frozen=True)
246 class LandBPlan:
247 """Planned land-b emission for the slice currently mid-land (§PMHF.3.4)."""
248
249 slice_id: str
250 land_a_id: str
251
252
253 def _slice_from_land_id(land_id: str, main_branch: str) -> str:
254 base = strip_land_parenthetical(land_id)
255 base = re.sub(
256 r"\s*(?:→|->)\s*" + re.escape(main_branch) + r"\s*$",
257 "",
258 base,
259 flags=re.IGNORECASE,
260 )
261 return base.strip()
262
263
264 def plan_land_b(
265 handover_text: str,
266 *,
267 d1: str | None,
268 freshness_state: str | None = None,
269 merged_pr_signal: bool = False,
270 main_branch: str = "main",
271 ) -> LandBPlan | None:
272 """Return a land-b plan when NEXT is land-a and closeout is post-merge incomplete.
273
274 §PMHF.3.4 rule 1: emit land-b for the same slice — never re-emit land-a.
275 """
276 if resolve_land_phase(handover_text) != LAND_PHASE_A:
277 return None
278 post_merge_incomplete = (
279 d1 == "drifted"
280 or freshness_state in {"drifted", "stale_marker"}
281 or merged_pr_signal
282 )
283 if not post_merge_incomplete:
284 return None
285 land_id = extract_land_id(handover_text) or ""
286 slice_id = _slice_from_land_id(land_id, main_branch) if land_id else ""
287 return LandBPlan(slice_id=slice_id or "land", land_a_id=land_id)
288
289
290 def render_land_b_next_session(
291 plan: LandBPlan,
292 *,
293 config: OverseerConfig,
294 sync_date: str,
295 ) -> str:
296 """Render the land-b NEXT SESSION body (frozen shape §PMHF.3.2)."""
297 main = config.vcs.git.main_branch or "main"
298 lines = [
299 f"## NEXT SESSION — {plan.slice_id} land-b (post-merge sync)",
300 "",
301 f"**Date:** {sync_date} ",
302 f"**Current position:** {plan.slice_id} land-a → land-b ",
303 "**Model:** Auto",
304 "",
305 "### THE ONE NEXT STEP — **Model: Auto**",
306 "",
307 f"Post-merge governance closeout for **{plan.slice_id}**: sync ROADMAP + HANDOVER to "
308 f"merged `{main}` so NEXT and paste no longer point at the pre-merge posture.",
309 "",
310 "| | |",
311 "| --- | --- |",
312 f"| **ID** | **{plan.slice_id} land-b (post-merge sync)** |",
313 f"| **Repo** | **{config.repo.name}** |",
314 f"| **Read first** | {_docs_read_first(config)} |",
315 f"| **Hard stops** | no silent commits to `{main}`; no Cursor-only dependency; "
316 "no freeze/BV redesign |",
317 ]
318 return "\n".join(lines)
319
320
321 def render_land_b_paste(plan: LandBPlan, *, config: OverseerConfig) -> str:
322 """Render the frozen land-b paste (§PMHF.3.2) inside the paste-ready anchor."""
323 main = config.vcs.git.main_branch or "main"
324 fence_lines = [
325 "Model: Auto",
326 f"ID: {plan.slice_id} land-b (post-merge sync)",
327 "land-phase: land-b",
328 "",
329 "Deliver:",
330 f"1. Fetch/pull latest {main} (regime-appropriate)",
331 "2. ok governance-sync --dry-run then apply when the plan is correct",
332 "3. Regenerate NEXT + paste so they no longer say wait-for-merge / land-a",
333 "4. Feature-branch commit bundling ROADMAP + HANDOVER (SD-17); open docs PR if needed",
334 "5. ok status --exit-code → 0 and ok land-closeout → 0 before claiming land complete",
335 "",
336 f"Hard stops: no silent commits to {main}; no Cursor-only dependency; "
337 "no freeze/BV redesign.",
338 ]
339 return "\n".join(
340 [
341 f"### Paste-ready prompt — {plan.slice_id} land-b",
342 "",
343 "```text",
344 "\n".join(fence_lines),
345 "```",
346 ]
347 )
348
349 HARD_STOPS = (
350 "No merge to `main` without Tier 3 · no secrets · no live posture flips · "
351 "no inventing NEXT when ambiguous"
352 )
353
354 BUILD_VERIFICATION_LINES = (
355 "Governance gates (mandatory — remind only; silence is not pass):",
356 "- Freeze review: /freeze-review-loop before Thinking freeze → DONE; "
357 "ok review --freeze when CLI green",
358 "- Build verification: /build-verification-review after every Auto "
359 "{step}b before ROADMAP DONE",
360 )
361
362
363 @dataclass(frozen=True)
364 class NextRegenDecision:
365 """Outcome of unambiguous-NEXT selection + emission planning."""
366
367 row: QueueRow | None
368 reason: str | None
369 emit_model: str | None
370 step_label: str | None
371 is_step_b: bool
372 advisory: str | None = None
373
374
375 def normalize_model_cell(model: str) -> str:
376 """Strip surrounding markdown bold from a queue Model cell."""
377 text = model.strip()
378 if text.startswith("**") and text.endswith("**") and len(text) > 4:
379 text = text[2:-2].strip()
380 return text
381
382
383
384
385 def select_unambiguous_next_row(
386 roadmap_text: str,
387 ) -> tuple[QueueRow | None, str | None]:
388 """Return the sole open queue row, or ``(None, reason)`` when ambiguous.
389
390 Reasons: ``zero_open_rows`` | ``multiple_open_rows`` | ``invalid_model_label``.
391 Split detection is separate (§GSP.5.2) and may add ``split_undetermined``.
392 """
393 rows = parse_queue_rows(roadmap_text)
394 open_rows = [row for row in rows if normalize_status(row.status) in OPEN_STATUSES]
395 if len(open_rows) == 0:
396 return None, REASON_ZERO
397 if len(open_rows) > 1:
398 return None, REASON_MULTIPLE
399 row = open_rows[0]
400 model = normalize_model_cell(row.model)
401 if not model or model not in MODEL_DISPLAY_LABELS:
402 return None, REASON_INVALID_MODEL
403 return row, None
404
405
406 def last_done_row(roadmap_text: str) -> QueueRow | None:
407 """Last DONE/MERGED queue row by table order, or None."""
408 done: QueueRow | None = None
409 for row in parse_queue_rows(roadmap_text):
410 if normalize_status(row.status) in DONE_STATUSES:
411 done = row
412 return done
413
414
415
416 def _path_under_docs(repo_root: Path, candidate: Path) -> Path | None:
417 """Return resolved path if it stays under ``repo_root/docs``; else None."""
418 docs = (repo_root / "docs").resolve()
419 try:
420 resolved = candidate.resolve()
421 resolved.relative_to(docs)
422 except (OSError, ValueError):
423 return None
424 if not resolved.is_file():
425 return None
426 return resolved
427
428
429 def discover_freeze_candidates(
430 repo_root: Path,
431 step_id: str,
432 deliverable: str,
433 ) -> list[Path]:
434 """Basename-only freeze discovery under ``docs/`` (§GSP.5.2 / §GSP.9)."""
435 docs = repo_root / "docs"
436 if not docs.is_dir():
437 return []
438
439 tokens: set[str] = set()
440 compact = step_id.strip()
441 if compact:
442 tokens.add(compact)
443 base = re.sub(r"-[ab]$", "", compact, flags=re.IGNORECASE)
444 if base:
445 tokens.add(base)
446
447 found: list[Path] = []
448 seen: set[Path] = set()
449 # Cap: only top-level docs/*.md names (no recursive walk).
450 for path in sorted(docs.glob("*.md")):
451 name_lower = path.name.lower()
452 if not name_lower.startswith("phase-"):
453 continue
454 for token in tokens:
455 if token.lower() in name_lower:
456 resolved = _path_under_docs(repo_root, path)
457 if resolved is not None and resolved not in seen:
458 seen.add(resolved)
459 found.append(resolved)
460 break
461
462 # Deliverable-cited path under docs/
463 for match in re.finditer(r"`?(docs/[^`\s|]+\.md)`?", deliverable):
464 rel = match.group(1)
465 if ".." in Path(rel).parts:
466 continue
467 candidate = repo_root / rel
468 resolved = _path_under_docs(repo_root, candidate)
469 if resolved is not None and resolved not in seen:
470 seen.add(resolved)
471 found.append(resolved)
472
473 return found
474
475
476 def decide_split_emission(
477 row: QueueRow,
478 repo_root: Path,
479 *,
480 config: OverseerConfig,
481 ) -> tuple[str | None, str | None, bool, str | None]:
482 """Return ``(emit_model, reason_or_None, is_step_b, advisory)`` (§FRV.6.5 / §FRV.6.5.1).
483
484 Gates both ``Thinking → Auto`` and plain ``Auto``. Excludes ``Operator + Auto``.
485 ``mechanical_only`` travels as advisory, never as reason (§FRV.6.6).
486 """
487 model = normalize_model_cell(row.model)
488
489 if model not in {SPLIT_MODEL, AUTO_MODEL}:
490 return model, None, False, None
491
492 step_id = compact_step_id(row.phase_label)
493 candidates = discover_freeze_candidates(repo_root, step_id, row.deliverable)
494
495 if model == AUTO_MODEL:
496 if not candidates:
497 return "Auto", None, False, None
498 auths = [
499 freeze_authorization_state(repo_root, path, phase_id=step_id, config=config)
500 for path in candidates
501 ]
502 if any(a.state == "blocked_by_operator" for a in auths):
503 blocked = next(a for a in auths if a.state == "blocked_by_operator")
504 return None, REASON_FREEZE_NOT_SUBSTANTIVE, False, blocked.advisory
505 if any(a.state == "substantive" for a in auths):
506 return "Auto", None, False, None
507 return None, REASON_FREEZE_NOT_SUBSTANTIVE, False, None
508
509 # Thinking → Auto
510 if not candidates:
511 return "Thinking", None, False, None
512
513 auths = [
514 freeze_authorization_state(repo_root, path, phase_id=step_id, config=config)
515 for path in candidates
516 ]
517 if any(a.state == "blocked_by_operator" for a in auths):
518 blocked = next(a for a in auths if a.state == "blocked_by_operator")
519 return "Thinking", None, False, blocked.advisory or ADVISORY_OPERATOR_BLOCK
520 has_substantive = any(a.state == "substantive" for a in auths)
521 has_non_pass = any(a.state == "non_pass" for a in auths)
522 if has_substantive and has_non_pass:
523 return None, REASON_SPLIT, False, None
524 if has_substantive and normalize_status(row.status) in OPEN_STATUSES:
525 return "Auto", None, True, None
526 if any(a.state == "mechanical_only" for a in auths) and not has_substantive:
527 return "Thinking", None, False, ADVISORY_MECHANICAL_ONLY
528 return "Thinking", None, False, None
529
530 def check_workspace_next_marker(
531 handover_text: str,
532 config: OverseerConfig,
533 ) -> str | None:
534 """Return ambiguity reason when workspace is set and NEXT marker is absent."""
535 if config.workspace is None:
536 return None
537 if NEXT_MARKER_RE.search(handover_text):
538 return None
539 return REASON_WORKSPACE_MARKER
540
541
542 def plan_next_regen(
543 *,
544 roadmap_text: str,
545 handover_text: str,
546 config: OverseerConfig,
547 repo_root: Path,
548 ) -> NextRegenDecision:
549 """Full §GSP.4 + §GSP.5.2 + §GSP.5.4 + §AFF.7 decision for NEXT/paste regen."""
550 row, reason = select_unambiguous_next_row(roadmap_text)
551 if row is None:
552 return NextRegenDecision(None, reason, None, None, False)
553
554 marker_reason = check_workspace_next_marker(handover_text, config)
555 if marker_reason:
556 return NextRegenDecision(None, marker_reason, None, None, False)
557
558 emit_model, split_reason, is_step_b, advisory = decide_split_emission(
559 row, repo_root, config=config
560 )
561 if split_reason:
562 return NextRegenDecision(None, split_reason, None, None, False)
563
564 step_id = compact_step_id(row.phase_label)
565 if normalize_model_cell(row.model) == SPLIT_MODEL:
566 step_label = f"{re.sub(r'-[ab]$', '', step_id, flags=re.IGNORECASE)}-{'b' if is_step_b else 'a'}"
567 else:
568 step_label = step_id
569
570 emit_model, is_step_b, advisory = _apply_adversarial_freeze_hold(
571 row=row,
572 repo_root=repo_root,
573 config=config,
574 emit_model=emit_model,
575 is_step_b=is_step_b,
576 advisory=advisory,
577 step_id=step_id,
578 )
579
580 return NextRegenDecision(row, None, emit_model, step_label, is_step_b, advisory=advisory)
581
582
583 def _apply_adversarial_freeze_hold(
584 *,
585 row: QueueRow,
586 repo_root: Path,
587 config: OverseerConfig,
588 emit_model: str | None,
589 is_step_b: bool,
590 advisory: str | None,
591 step_id: str,
592 ) -> tuple[str | None, bool, str | None]:
593 """Apply AFF Trigger A / Trigger B after FRV split emission (§AFF.7.1)."""
594 model = normalize_model_cell(row.model)
595 if model == OPERATOR_AUTO_MODEL:
596 return emit_model, is_step_b, advisory
597 if aff_hold_bypassed(config):
598 return emit_model, is_step_b, advisory
599 if emit_model is None:
600 return emit_model, is_step_b, advisory
601
602 candidates = discover_freeze_candidates(repo_root, step_id, row.deliverable)
603
604 # Trigger A — FRV would emit Auto
605 if emit_model == "Auto" or is_step_b:
606 if not candidates:
607 return emit_model, is_step_b, advisory
608 authorizing = frv_authorizing_freeze_paths(
609 repo_root, candidates, phase_id=step_id, config=config
610 )
611 if not authorizing:
612 return emit_model, is_step_b, advisory
613 states = [
614 adversarial_authorization_state(repo_root, path, config=config)
615 for path in authorizing
616 ]
617 if all(s.state in {"pass", "skipped", "off"} for s in states):
618 if any(s.state == "skipped" for s in states):
619 return emit_model, is_step_b, ADVISORY_ADVERSARIAL_FREEZE_SKIP
620 return emit_model, is_step_b, advisory
621 # pending or absent → hold
622 return "Thinking", False, ADVISORY_ADVERSARIAL_FREEZE_PENDING
623
624 # Trigger B — Thinking emission after author freeze-review-loop complete
625 if emit_model != "Thinking":
626 return emit_model, is_step_b, advisory
627 if not candidates:
628 return emit_model, is_step_b, advisory
629
630 completed_states: list[str] = []
631 for path in candidates:
632 frv = freeze_authorization_state(
633 repo_root, path, phase_id=step_id, config=config
634 )
635 stamp_digest = None
636 current_digest = ""
637 try:
638 rel = path.resolve().relative_to(repo_root.resolve()).as_posix()
639 parsed = parse_artifact(path, rel_path=rel)
640 current_digest = artifact_digest(parsed)
641 stamp = extract_existing_stamp(parsed)
642 if isinstance(stamp, dict):
643 raw = stamp.get("artifact_digest")
644 stamp_digest = raw if isinstance(raw, str) else None
645 except Exception:
646 continue
647 if not author_loop_complete(
648 frv.state,
649 stamp_digest=stamp_digest,
650 current_digest=current_digest,
651 ):
652 continue
653 aff = adversarial_authorization_state(repo_root, path, config=config)
654 if aff.state == "off":
655 continue
656 completed_states.append(aff.state)
657
658 if not completed_states:
659 return emit_model, is_step_b, advisory
660 if any(s in {"pending", "absent"} for s in completed_states):
661 return "Thinking", False, ADVISORY_ADVERSARIAL_FREEZE_PENDING
662 # pass / skipped / off — leave Thinking emission (do not rewrite to Auto)
663 return emit_model, is_step_b, advisory
664
665
666 def _branch_value(config: OverseerConfig, phase_id: str) -> str:
667 pattern = config.vcs.git.feature_branch_pattern
668 if not pattern or "{slug}" not in pattern:
669 return "`unknown`"
670 slug = re.sub(r"[^a-z0-9]+", "-", phase_id.lower()).strip("-")
671 if not slug:
672 return "`unknown`"
673 return f"`{pattern.replace('{slug}', slug)}`"
674
675
676 def _docs_read_first(config: OverseerConfig) -> str:
677 roadmap = join_docs_rel(config.repo.root_relative_docs, config.docs.roadmap)
678 handover = join_docs_rel(config.repo.root_relative_docs, config.docs.handover)
679 return f"`{roadmap}`; `{handover}`"
680
681
682 def _landed_table_row(roadmap_text: str) -> str:
683 done = last_done_row(roadmap_text)
684 if done is None:
685 return "| _(none)_ | Queue has no DONE/MERGED row yet |"
686 slice_id = compact_step_id(done.phase_label)
687 deliverable = done.deliverable.strip() or "_(no deliverable)_"
688 return f"| **{slice_id}** | {deliverable} |"
689
690
691 def _current_position(roadmap_text: str, open_row: QueueRow) -> str:
692 done = last_done_row(roadmap_text)
693 open_id = compact_step_id(open_row.phase_label)
694 if done is None:
695 return f"→ {open_id}"
696 return f"{compact_step_id(done.phase_label)} → {open_id}"
697
698
699 def ensure_primary_next_marker(handover_text: str, config: OverseerConfig) -> str:
700 """Insert PRIMARY next marker above NEXT SESSION when absent and allowed."""
701 if NEXT_MARKER_RE.search(handover_text):
702 return handover_text
703 if config.workspace is not None:
704 return handover_text
705 marker = "<!-- overseer:next role=primary lane=product status=live -->"
706 heading = "## NEXT SESSION"
707 if heading not in handover_text:
708 return handover_text
709 return handover_text.replace(heading, f"{marker}\n{heading}", 1)
710
711
712 def render_next_session(
713 *,
714 decision: NextRegenDecision,
715 roadmap_text: str,
716 config: OverseerConfig,
717 sync_date: str,
718 ) -> str:
719 """Render ``next-session`` anchor body (§GSP.5.3) — no paste fence."""
720 assert decision.row is not None
721 assert decision.emit_model is not None
722 assert decision.step_label is not None
723 row = decision.row
724 model = decision.emit_model
725 step_id = decision.step_label
726 title = phase_tokens(row.phase_label)[0] if phase_tokens(row.phase_label) else step_id
727 branch = _branch_value(config, step_id)
728 read_first = _docs_read_first(config)
729 position = _current_position(roadmap_text, row)
730 landed = _landed_table_row(roadmap_text)
731
732 if decision.advisory == ADVISORY_ADVERSARIAL_FREEZE_PENDING:
733 one_next = (
734 "Adversarial freeze review (attack posture) — different chat from "
735 "the author; try to kill the freeze before Auto may start."
736 )
737 else:
738 one_next = row.deliverable.strip() or f"Advance {step_id}."
739
740 lines = [
741 f"## NEXT SESSION — {title}",
742 "",
743 f"**Date:** {sync_date} ",
744 f"**Current position:** {position} ",
745 f"**Model:** {model}",
746 "",
747 "### What just landed",
748 "",
749 "| Slice | Deliverable |",
750 "| --- | --- |",
751 landed,
752 "",
753 f"### THE ONE NEXT STEP — **Model: {model}**",
754 "",
755 one_next,
756 "",
757 "| | |",
758 "| --- | --- |",
759 f"| **ID** | **{step_id}** |",
760 f"| **Branch** | {branch} |",
761 f"| **Repo** | **{config.repo.name}** |",
762 f"| **Read first** | {read_first} |",
763 f"| **Hard stops** | {HARD_STOPS} |",
764 ]
765 return "\n".join(lines)
766
767
768 def render_paste_ready(
769 *,
770 decision: NextRegenDecision,
771 config: OverseerConfig,
772 ) -> str:
773 """Render ``paste-ready-prompt`` anchor body (§GSP.5.3 / H16 / §AFF.7.2)."""
774 assert decision.row is not None
775 assert decision.emit_model is not None
776 assert decision.step_label is not None
777 row = decision.row
778 model = decision.emit_model
779 step_id = decision.step_label
780 branch = _branch_value(config, step_id).strip("`")
781 read_first = _docs_read_first(config)
782 title = phase_tokens(row.phase_label)[0] if phase_tokens(row.phase_label) else step_id
783
784 if decision.advisory == ADVISORY_ADVERSARIAL_FREEZE_PENDING:
785 cited = None
786 for match in re.finditer(r"`?(docs/[^`\s|]+\.md)`?", row.deliverable):
787 cited = match.group(1)
788 break
789 artifact_line = cited or (row.deliverable.strip() or step_id)
790 fence_lines = [
791 f"{step_id} — adversarial freeze review ({config.repo.name}).",
792 "",
793 "Model: Thinking",
794 f"Repo: {config.repo.name}",
795 f"Branch: {branch}",
796 f"Step: {step_id}",
797 "Authority: authoritative",
798 "Posture: attack — try to kill the freeze; do not rubber-stamp close",
799 "",
800 "This MUST be a different chat from the author freeze-review-loop session.",
801 "If this is the author session, stop. Prefer a different model than the author",
802 "(preference only; the kit does not gate model labels).",
803 "",
804 f"Read the freeze artifact: `{artifact_line}`",
805 f"Also read: {read_first}.",
806 "",
807 "Cite every finding as path:line.",
808 "",
809 "Mandatory Review-record / restamp / FRV-rebind / AFF-append transaction:",
810 "1. Finish the review and decide pass|findings|blocked WITHOUT appending yet.",
811 "2. Write the adversarial round into the freeze artifact Review-record table;",
812 " complete every other artifact edit now.",
813 "3. Run ok review --freeze <artifact>; require mechanical pass; stamp digest",
814 " must equal current FRV artifact_digest D.",
815 "4. If Auto requires FRV authorization, append freeze_review pass for the same",
816 " frozen_spec + artifact_digest D; verify the ledger.",
817 "5. Append adversarial_freeze as the final binding operation with the",
818 " actual verdict and same frozen_spec + artifact_digest D.",
819 " On pass: §AFF.5.2 fields only — actor_role: verifier,",
820 " actor_session_id: <THIS_CHAT_SESSION_ID>, phase_id, round,",
821 " reviewer_model, frozen_spec, artifact_digest,",
822 " aff_verdict: pass, aff_posture: attack, and",
823 " producer_session_id: <AUTHOR_PRODUCER_SESSION_NONCE>",
824 " (do not scrape IDE session ids). Verify the ledger again.",
825 "6. Do not edit the freeze artifact after step 5.",
826 "",
827 "On findings/blocked: append that negative verdict as the final step;",
828 "never omit a negative append merely to preserve an older pass.",
829 "Under suggest, operator skip is a separate owner append (aff_verdict: skip)",
830 "— the reviewer chat does not skip for the operator.",
831 "",
832 "The kit performs no model call and holds no key.",
833 "No merge to main.",
834 "",
835 "Hard stops: No merge to main without Tier 3 · no secrets · no live posture",
836 "flips · no model call from the kit CLI · no redesign of the freeze",
837 ]
838 else:
839 fence_lines = [
840 f"{step_id} — {title} ({config.repo.name}).",
841 "",
842 f"Model: {model}",
843 f"Repo: {config.repo.name}",
844 f"Branch: {branch}",
845 f"Step: {step_id}",
846 "Authority: authoritative",
847 "",
848 f"Read first: {read_first}.",
849 "",
850 "Deliverables:",
851 f"- {row.deliverable.strip() or step_id}",
852 "",
853 f"Hard stops: {HARD_STOPS}",
854 "",
855 "Governance sync: update roadmap + handover on completion.",
856 ]
857 if model == "Auto" or decision.is_step_b:
858 fence_lines.append("")
859 fence_lines.extend(BUILD_VERIFICATION_LINES)
860
861 fence_body = "\n".join(fence_lines)
862 return "\n".join(
863 [
864 f"### Paste-ready prompt — {step_id}",
865 "",
866 "```text",
867 fence_body,
868 "```",
869 ]
870 )
871
872
873 def format_next_regen_token(decision: NextRegenDecision) -> str:
874 """Plan/result token for dry-run and change-log (§GSP.4.3 / §GSP.6.2 / §FRV.6.6)."""
875 if decision.row is not None and decision.reason is None:
876 if decision.advisory:
877 return f"next_regen: regenerated (advisory={decision.advisory})"
878 return "next_regen: regenerated"
879 reason = decision.reason or "unknown"
880 return f"next_regen: human_authorship_required ({reason})"
881
882
883 def format_change_log_fragment(decision: NextRegenDecision) -> str:
884 """Additive change-log fragment (§GSP.6.2 / §FRV.6.6)."""
885 if decision.row is not None and decision.reason is None:
886 if decision.advisory:
887 return f"next_regen=regenerated:advisory={decision.advisory}"
888 return "next_regen=regenerated"
889 reason = decision.reason or "unknown"
890 return f"next_regen=human_authorship_required:{reason}"