@@ -39,6 +39,17 @@ readonly FIELD_ASK_CODEX_QUESTION="ask_codex_question"
3939readonly FIELD_SESSION_ID=" session_id"
4040readonly FIELD_AGENT_TEAMS=" agent_teams"
4141readonly 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
418435parse_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 "
597723is_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"
679820summary_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
706920is_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 "
13281542goal_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