Skip to content

Commit a8f85e8

Browse files
committed
Merge PR #48: Harden RLCR against mainline drift
Add anti-drift mechanisms to prevent later RLCR rounds from drifting away from the original plan: - Round contracts as per-round execution anchors - Mainline/blocking/queued task classification - Drift/replan state machine with circuit breaker - Monitor visibility for drift status and mainline verdict
2 parents fe1832d + 9b961a8 commit a8f85e8

37 files changed

Lines changed: 2242 additions & 173 deletions

hooks/check-todos-from-transcript.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,26 @@
1515
echo '{"session_id": "...", "transcript_path": "/path/to/transcript.jsonl"}' | python3 check-todos-from-transcript.py
1616
"""
1717
import json
18+
import re
1819
import sys
1920
from pathlib import Path
2021
from typing import List, Tuple
2122

2223

24+
LANE_PREFIX_PATTERN = re.compile(r"^\s*\[(mainline|blocking|queued)\](?:\s|$)", re.IGNORECASE)
25+
26+
27+
def classify_lane(*parts: str) -> str:
28+
"""Infer the task lane from content, defaulting to blocking for safety."""
29+
for part in parts:
30+
if not part:
31+
continue
32+
match = LANE_PREFIX_PATTERN.match(part)
33+
if match:
34+
return match.group(1).lower()
35+
return "blocking"
36+
37+
2338
def extract_tool_calls_from_entry(entry: dict) -> List[Tuple[str, dict]]:
2439
"""
2540
Extract tool calls from a transcript entry.
@@ -92,10 +107,14 @@ def find_incomplete_todos_from_transcript(transcript_path: Path) -> List[dict]:
92107
status = todo.get("status", "")
93108
content = todo.get("content", "")
94109
if status != "completed":
110+
lane = classify_lane(content)
111+
if lane == "queued":
112+
continue
95113
incomplete.append({
96114
"status": status,
97115
"content": content,
98116
"source": "todo",
117+
"lane": lane,
99118
})
100119

101120
return incomplete
@@ -134,11 +153,15 @@ def find_incomplete_tasks_from_directory(session_id: str, tasks_base_dir: str =
134153
description = task.get("description", "")
135154
task_id = task_file.stem # Filename without .json
136155
content = subject or description or f"Task {task_id}"
156+
lane = classify_lane(subject, description)
157+
if lane == "queued":
158+
continue
137159
incomplete.append({
138160
"status": status,
139161
"content": content,
140162
"source": "task",
141163
"task_id": task_id,
164+
"lane": lane,
142165
})
143166
except (json.JSONDecodeError, OSError):
144167
# Skip malformed or unreadable task files
@@ -184,11 +207,13 @@ def main():
184207
status = item.get("status", "unknown")
185208
content = item.get("content", "")
186209
source = item.get("source", "unknown")
210+
lane = item.get("lane", "blocking")
211+
lane_marker = f"[{lane}]"
187212
if source == "task":
188213
task_id = item.get("task_id", "?")
189-
output_lines.append(f" - [{status}] (Task #{task_id}) {content}")
214+
output_lines.append(f" - [{status}] {lane_marker} (Task #{task_id}) {content}")
190215
else:
191-
output_lines.append(f" - [{status}] {content}")
216+
output_lines.append(f" - [{status}] {lane_marker} {content}")
192217

193218
# Output marker and incomplete items both to stdout
194219
print("INCOMPLETE_TODOS")

hooks/lib/loop-common.sh

Lines changed: 228 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ readonly FIELD_ASK_CODEX_QUESTION="ask_codex_question"
3939
readonly FIELD_SESSION_ID="session_id"
4040
readonly FIELD_AGENT_TEAMS="agent_teams"
4141
readonly FIELD_PRIVACY_MODE="privacy_mode"
42+
readonly FIELD_MAINLINE_STALL_COUNT="mainline_stall_count"
43+
readonly FIELD_LAST_MAINLINE_VERDICT="last_mainline_verdict"
44+
readonly FIELD_DRIFT_STATUS="drift_status"
45+
46+
readonly MAINLINE_VERDICT_ADVANCED="advanced"
47+
readonly MAINLINE_VERDICT_STALLED="stalled"
48+
readonly MAINLINE_VERDICT_REGRESSED="regressed"
49+
readonly MAINLINE_VERDICT_UNKNOWN="unknown"
50+
51+
readonly DRIFT_STATUS_NORMAL="normal"
52+
readonly DRIFT_STATUS_REPLAN_REQUIRED="replan_required"
4253

4354
# Default Codex configuration (single source of truth - all scripts reference this)
4455
# Scripts can pre-set DEFAULT_CODEX_MODEL/DEFAULT_CODEX_EFFORT before sourcing to override.
@@ -393,6 +404,9 @@ _parse_state_fields() {
393404
STATE_SESSION_ID=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_SESSION_ID}:" | sed "s/${FIELD_SESSION_ID}: *//" || true)
394405
STATE_AGENT_TEAMS=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_AGENT_TEAMS}:" | sed "s/${FIELD_AGENT_TEAMS}: *//" | tr -d ' ' || true)
395406
STATE_PRIVACY_MODE=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_PRIVACY_MODE}:" | sed "s/${FIELD_PRIVACY_MODE}: *//" | tr -d ' ' || true)
407+
STATE_MAINLINE_STALL_COUNT=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_MAINLINE_STALL_COUNT}:" | sed "s/${FIELD_MAINLINE_STALL_COUNT}: *//" | tr -d ' ' || true)
408+
STATE_LAST_MAINLINE_VERDICT=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_LAST_MAINLINE_VERDICT}:" | sed "s/${FIELD_LAST_MAINLINE_VERDICT}: *//" | tr -d ' ' || true)
409+
STATE_DRIFT_STATUS=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_DRIFT_STATUS}:" | sed "s/${FIELD_DRIFT_STATUS}: *//" | tr -d ' ' || true)
396410
}
397411

398412
# Parse state file frontmatter and set variables (tolerant mode with defaults)
@@ -413,6 +427,9 @@ _parse_state_fields() {
413427
# STATE_FULL_REVIEW_ROUND - interval for Full Alignment Check (default: 5)
414428
# STATE_ASK_CODEX_QUESTION - "true" or "false" (v1.6.5+)
415429
# STATE_AGENT_TEAMS - "true" or "false"
430+
# STATE_MAINLINE_STALL_COUNT - consecutive stalled/regressed implementation rounds
431+
# STATE_LAST_MAINLINE_VERDICT - advanced/stalled/regressed/unknown
432+
# STATE_DRIFT_STATUS - normal/replan_required
416433
# Returns: 0 on success, 1 if file not found
417434
# Note: For strict validation, use parse_state_file_strict() instead
418435
parse_state_file() {
@@ -437,6 +454,9 @@ parse_state_file() {
437454
STATE_AGENT_TEAMS="${STATE_AGENT_TEAMS:-false}"
438455
# Default privacy_mode to "true" for legacy loops that pre-date this field
439456
STATE_PRIVACY_MODE="${STATE_PRIVACY_MODE:-true}"
457+
STATE_MAINLINE_STALL_COUNT="${STATE_MAINLINE_STALL_COUNT:-0}"
458+
STATE_LAST_MAINLINE_VERDICT="${STATE_LAST_MAINLINE_VERDICT:-$MAINLINE_VERDICT_UNKNOWN}"
459+
STATE_DRIFT_STATUS="${STATE_DRIFT_STATUS:-$DRIFT_STATUS_NORMAL}"
440460
# STATE_REVIEW_STARTED left as-is (empty if missing, to allow schema validation)
441461

442462
return 0
@@ -512,10 +532,116 @@ parse_state_file_strict() {
512532
STATE_FULL_REVIEW_ROUND="${STATE_FULL_REVIEW_ROUND:-5}"
513533
STATE_ASK_CODEX_QUESTION="${STATE_ASK_CODEX_QUESTION:-true}"
514534
STATE_AGENT_TEAMS="${STATE_AGENT_TEAMS:-false}"
535+
STATE_MAINLINE_STALL_COUNT="${STATE_MAINLINE_STALL_COUNT:-0}"
536+
STATE_LAST_MAINLINE_VERDICT="${STATE_LAST_MAINLINE_VERDICT:-$MAINLINE_VERDICT_UNKNOWN}"
537+
STATE_DRIFT_STATUS="${STATE_DRIFT_STATUS:-$DRIFT_STATUS_NORMAL}"
515538

516539
return 0
517540
}
518541

542+
# Normalize mainline progress verdict to a safe enum.
543+
# Usage: normalize_mainline_progress_verdict "ADVANCED"
544+
normalize_mainline_progress_verdict() {
545+
local verdict_lower
546+
verdict_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
547+
548+
case "$verdict_lower" in
549+
"$MAINLINE_VERDICT_ADVANCED"|"$MAINLINE_VERDICT_STALLED"|"$MAINLINE_VERDICT_REGRESSED")
550+
echo "$verdict_lower"
551+
;;
552+
*)
553+
echo "$MAINLINE_VERDICT_UNKNOWN"
554+
;;
555+
esac
556+
}
557+
558+
# Normalize drift status to a safe enum.
559+
# Usage: normalize_drift_status "replan_required"
560+
normalize_drift_status() {
561+
local status_lower
562+
status_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
563+
564+
case "$status_lower" in
565+
"$DRIFT_STATUS_REPLAN_REQUIRED")
566+
echo "$DRIFT_STATUS_REPLAN_REQUIRED"
567+
;;
568+
*)
569+
echo "$DRIFT_STATUS_NORMAL"
570+
;;
571+
esac
572+
}
573+
574+
# Extract "Mainline Progress Verdict" from Codex review content.
575+
# Outputs one of: advanced, stalled, regressed, unknown
576+
# Usage: extract_mainline_progress_verdict "$review_content"
577+
extract_mainline_progress_verdict() {
578+
local review_content="$1"
579+
local verdict_line
580+
local verdict_value
581+
582+
verdict_line=$(printf '%s\n' "$review_content" | grep -Ei 'Mainline Progress Verdict:[[:space:]]*(ADVANCED|STALLED|REGRESSED)([^A-Za-z]|$)' | tail -1 || true)
583+
if [[ -z "$verdict_line" ]]; then
584+
echo "$MAINLINE_VERDICT_UNKNOWN"
585+
return
586+
fi
587+
588+
verdict_value=$(printf '%s\n' "$verdict_line" | sed -E 's/.*Mainline Progress Verdict:[[:space:]]*(ADVANCED|STALLED|REGRESSED).*/\1/I')
589+
normalize_mainline_progress_verdict "$verdict_value"
590+
}
591+
592+
# Upsert simple YAML frontmatter fields in a state file.
593+
# Values must not contain newlines.
594+
# Usage: upsert_state_fields "/path/to/state.md" "field=value" "other=value"
595+
upsert_state_fields() {
596+
local state_file="$1"
597+
shift
598+
599+
local temp_file="${state_file}.tmp.$$"
600+
601+
awk -v assignments="$*" '
602+
BEGIN {
603+
count = split(assignments, pairs, " ");
604+
for (i = 1; i <= count; i++) {
605+
split(pairs[i], kv, "=");
606+
keys[kv[1]] = kv[2];
607+
order[i] = kv[1];
608+
}
609+
separator_count = 0;
610+
}
611+
{
612+
if ($0 == "---") {
613+
separator_count++;
614+
if (separator_count == 2) {
615+
for (i = 1; i <= count; i++) {
616+
key = order[i];
617+
if (!(key in seen)) {
618+
print key ": " keys[key];
619+
seen[key] = 1;
620+
}
621+
}
622+
}
623+
print;
624+
next;
625+
}
626+
627+
handled = 0;
628+
for (i = 1; i <= count; i++) {
629+
key = order[i];
630+
if ($0 ~ ("^" key ":")) {
631+
print key ": " keys[key];
632+
seen[key] = 1;
633+
handled = 1;
634+
break;
635+
}
636+
}
637+
638+
if (!handled) {
639+
print;
640+
}
641+
}
642+
' "$state_file" > "$temp_file" && mv "$temp_file" "$state_file"
643+
}
644+
519645
# Detect review issues from codex review log file
520646
# Returns:
521647
# 0 - issues found (caller should continue review loop)
@@ -593,7 +719,7 @@ to_lower() {
593719
}
594720

595721
# Check if a path (lowercase) matches a round file pattern
596-
# Usage: is_round_file "$lowercase_path" "summary|prompt|todos"
722+
# Usage: is_round_file "$lowercase_path" "summary|prompt|todos|contract"
597723
is_round_file_type() {
598724
local path_lower="$1"
599725
local file_type="$2"
@@ -610,7 +736,7 @@ extract_round_number() {
610736
filename_lower=$(to_lower "$filename")
611737

612738
# Use sed for portable regex extraction (works in both bash and zsh)
613-
echo "$filename_lower" | sed -n 's/.*round-\([0-9][0-9]*\)-\(summary\|prompt\|todos\)\.md$/\1/p'
739+
echo "$filename_lower" | sed -n 's/.*round-\([0-9][0-9]*\)-\(summary\|prompt\|todos\|contract\)\.md$/\1/p'
614740
}
615741

616742
# Check if a file is in the allowlist for the active loop
@@ -674,6 +800,21 @@ You cannot modify finalize-state.md. This file is managed by the loop system dur
674800
load_and_render_safe "$TEMPLATE_DIR" "block/finalize-state-file-modification.md" "$fallback"
675801
}
676802

803+
# Standard message for blocking round contract access during Finalize Phase
804+
# Usage: finalize_contract_blocked_message "read"
805+
finalize_contract_blocked_message() {
806+
local action="$1"
807+
local fallback="# Finalize Contract Access Blocked
808+
809+
There is no active round contract during the Finalize Phase.
810+
811+
Do not {{ACTION}} historical round contract files.
812+
Use finalize-summary.md for finalize-only notes and goal-tracker.md for current state."
813+
814+
load_and_render_safe "$TEMPLATE_DIR" "block/finalize-contract-access.md" "$fallback" \
815+
"ACTION=$action"
816+
}
817+
677818
# Standard message for blocking summary file modifications via Bash
678819
# Usage: summary_bash_blocked_message "$correct_summary_path"
679820
summary_bash_blocked_message() {
@@ -702,6 +843,79 @@ is_goal_tracker_path() {
702843
echo "$path_lower" | grep -qE 'goal-tracker\.md$'
703844
}
704845

846+
# Extract the immutable section from a goal-tracker content stream.
847+
# Supports both current trackers (with --- separator) and older trackers
848+
# that jump directly from IMMUTABLE SECTION to MUTABLE SECTION.
849+
extract_goal_tracker_immutable_from_stream() {
850+
awk '
851+
/^## IMMUTABLE SECTION[[:space:]]*$/ { capture=1 }
852+
capture && /^## MUTABLE SECTION[[:space:]]*$/ { exit }
853+
capture && /^---[[:space:]]*$/ { exit }
854+
capture { print }
855+
'
856+
}
857+
858+
# Extract the immutable section from an on-disk goal-tracker file.
859+
# Usage: extract_goal_tracker_immutable_from_file "/path/to/goal-tracker.md"
860+
extract_goal_tracker_immutable_from_file() {
861+
local tracker_file="$1"
862+
if [[ ! -f "$tracker_file" ]]; then
863+
return 1
864+
fi
865+
extract_goal_tracker_immutable_from_stream < "$tracker_file"
866+
}
867+
868+
# Extract the immutable section from an in-memory goal-tracker string.
869+
# Usage: extract_goal_tracker_immutable_from_text "$content"
870+
extract_goal_tracker_immutable_from_text() {
871+
local tracker_content="$1"
872+
printf '%s' "$tracker_content" | extract_goal_tracker_immutable_from_stream
873+
}
874+
875+
# Check whether a proposed goal-tracker update preserves the immutable section.
876+
# Usage: goal_tracker_mutable_update_allowed "/path/to/current.md" "$new_content"
877+
goal_tracker_mutable_update_allowed() {
878+
local tracker_file="$1"
879+
local updated_content="$2"
880+
881+
local current_immutable=""
882+
local updated_immutable=""
883+
current_immutable=$(extract_goal_tracker_immutable_from_file "$tracker_file" 2>/dev/null || true)
884+
updated_immutable=$(extract_goal_tracker_immutable_from_text "$updated_content" 2>/dev/null || true)
885+
886+
[[ -n "$current_immutable" ]] || return 1
887+
[[ "$current_immutable" == "$updated_immutable" ]]
888+
}
889+
890+
# Render the post-edit contents for a literal Edit operation.
891+
# Returns non-zero if the edit preview cannot be produced.
892+
# Usage: preview_edit_result "/path/to/file" "$old_string" "$new_string" "true|false"
893+
preview_edit_result() {
894+
local file_path="$1"
895+
local old_string="$2"
896+
local new_string="$3"
897+
local replace_all="${4:-false}"
898+
899+
command -v perl >/dev/null 2>&1 || return 1
900+
901+
FILE_PATH="$file_path" \
902+
OLD_STRING="$old_string" \
903+
NEW_STRING="$new_string" \
904+
REPLACE_ALL="$replace_all" \
905+
perl -0pe '
906+
BEGIN {
907+
$old = $ENV{"OLD_STRING"};
908+
$new = $ENV{"NEW_STRING"};
909+
$replace_all = $ENV{"REPLACE_ALL"} eq "true";
910+
}
911+
if ($replace_all) {
912+
s/\Q$old\E/$new/g;
913+
} else {
914+
s/\Q$old\E/$new/;
915+
}
916+
' "$file_path"
917+
}
918+
705919
# Check if a path (lowercase) targets state.md
706920
is_state_file_path() {
707921
local path_lower="$1"
@@ -1324,17 +1538,24 @@ command_modifies_file() {
13241538
}
13251539

13261540
# Standard message for blocking goal-tracker modifications after Round 0
1327-
# Usage: goal_tracker_blocked_message "$current_round" "$summary_file_path"
1541+
# Usage: goal_tracker_blocked_message "$current_round" "$correct_goal_tracker_path"
13281542
goal_tracker_blocked_message() {
13291543
local current_round="$1"
1330-
local summary_file="$2"
1331-
local fallback="# Goal Tracker Modification Blocked (Round {{CURRENT_ROUND}})
1544+
local correct_path="$2"
1545+
local fallback="# Goal Tracker Update Blocked (Round {{CURRENT_ROUND}})
1546+
1547+
After Round 0, you may update only the **MUTABLE SECTION** of the active goal tracker.
1548+
1549+
Use Write or Edit on: {{CORRECT_PATH}}
13321550
1333-
After Round 0, only Codex can modify the Goal Tracker. Include a Goal Tracker Update Request in your summary: {{SUMMARY_FILE}}"
1551+
Rules:
1552+
- Keep the **IMMUTABLE SECTION** unchanged
1553+
- Do not modify `goal-tracker.md` via Bash
1554+
- Do not write to an old loop session's tracker"
13341555

13351556
load_and_render_safe "$TEMPLATE_DIR" "block/goal-tracker-modification.md" "$fallback" \
13361557
"CURRENT_ROUND=$current_round" \
1337-
"SUMMARY_FILE=$summary_file"
1558+
"CORRECT_PATH=$correct_path"
13381559
}
13391560

13401561
# End the loop by renaming state.md to indicate exit reason

0 commit comments

Comments
 (0)