Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions hooks/check-todos-from-transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,26 @@
echo '{"session_id": "...", "transcript_path": "/path/to/transcript.jsonl"}' | python3 check-todos-from-transcript.py
"""
import json
import re
import sys
from pathlib import Path
from typing import List, Tuple


LANE_PREFIX_PATTERN = re.compile(r"^\s*\[(mainline|blocking|queued)\](?:\s|$)", re.IGNORECASE)


def classify_lane(*parts: str) -> str:
"""Infer the task lane from content, defaulting to blocking for safety."""
for part in parts:
if not part:
continue
match = LANE_PREFIX_PATTERN.match(part)
if match:
return match.group(1).lower()
return "blocking"


def extract_tool_calls_from_entry(entry: dict) -> List[Tuple[str, dict]]:
"""
Extract tool calls from a transcript entry.
Expand Down Expand Up @@ -92,10 +107,14 @@ def find_incomplete_todos_from_transcript(transcript_path: Path) -> List[dict]:
status = todo.get("status", "")
content = todo.get("content", "")
if status != "completed":
lane = classify_lane(content)
if lane == "queued":
continue
incomplete.append({
"status": status,
"content": content,
"source": "todo",
"lane": lane,
})

return incomplete
Expand Down Expand Up @@ -134,11 +153,15 @@ def find_incomplete_tasks_from_directory(session_id: str, tasks_base_dir: str =
description = task.get("description", "")
task_id = task_file.stem # Filename without .json
content = subject or description or f"Task {task_id}"
lane = classify_lane(subject, description)
if lane == "queued":
continue
incomplete.append({
"status": status,
"content": content,
"source": "task",
"task_id": task_id,
"lane": lane,
})
except (json.JSONDecodeError, OSError):
# Skip malformed or unreadable task files
Expand Down Expand Up @@ -184,11 +207,13 @@ def main():
status = item.get("status", "unknown")
content = item.get("content", "")
source = item.get("source", "unknown")
lane = item.get("lane", "blocking")
lane_marker = f"[{lane}]"
if source == "task":
task_id = item.get("task_id", "?")
output_lines.append(f" - [{status}] (Task #{task_id}) {content}")
output_lines.append(f" - [{status}] {lane_marker} (Task #{task_id}) {content}")
else:
output_lines.append(f" - [{status}] {content}")
output_lines.append(f" - [{status}] {lane_marker} {content}")

# Output marker and incomplete items both to stdout
print("INCOMPLETE_TODOS")
Expand Down
235 changes: 228 additions & 7 deletions hooks/lib/loop-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ readonly FIELD_FULL_REVIEW_ROUND="full_review_round"
readonly FIELD_ASK_CODEX_QUESTION="ask_codex_question"
readonly FIELD_SESSION_ID="session_id"
readonly FIELD_AGENT_TEAMS="agent_teams"
readonly FIELD_MAINLINE_STALL_COUNT="mainline_stall_count"
readonly FIELD_LAST_MAINLINE_VERDICT="last_mainline_verdict"
readonly FIELD_DRIFT_STATUS="drift_status"

readonly MAINLINE_VERDICT_ADVANCED="advanced"
readonly MAINLINE_VERDICT_STALLED="stalled"
readonly MAINLINE_VERDICT_REGRESSED="regressed"
readonly MAINLINE_VERDICT_UNKNOWN="unknown"

readonly DRIFT_STATUS_NORMAL="normal"
readonly DRIFT_STATUS_REPLAN_REQUIRED="replan_required"

# Default Codex configuration (single source of truth - all scripts reference this)
# Scripts can pre-set DEFAULT_CODEX_MODEL/DEFAULT_CODEX_EFFORT before sourcing to override.
Expand Down Expand Up @@ -364,6 +375,9 @@ _parse_state_fields() {
STATE_ASK_CODEX_QUESTION=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_ASK_CODEX_QUESTION}:" | sed "s/${FIELD_ASK_CODEX_QUESTION}: *//" | tr -d ' ' || true)
STATE_SESSION_ID=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_SESSION_ID}:" | sed "s/${FIELD_SESSION_ID}: *//" || true)
STATE_AGENT_TEAMS=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_AGENT_TEAMS}:" | sed "s/${FIELD_AGENT_TEAMS}: *//" | tr -d ' ' || true)
STATE_MAINLINE_STALL_COUNT=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_MAINLINE_STALL_COUNT}:" | sed "s/${FIELD_MAINLINE_STALL_COUNT}: *//" | tr -d ' ' || true)
STATE_LAST_MAINLINE_VERDICT=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_LAST_MAINLINE_VERDICT}:" | sed "s/${FIELD_LAST_MAINLINE_VERDICT}: *//" | tr -d ' ' || true)
STATE_DRIFT_STATUS=$(echo "$STATE_FRONTMATTER" | grep "^${FIELD_DRIFT_STATUS}:" | sed "s/${FIELD_DRIFT_STATUS}: *//" | tr -d ' ' || true)
}

# Parse state file frontmatter and set variables (tolerant mode with defaults)
Expand All @@ -384,6 +398,9 @@ _parse_state_fields() {
# STATE_FULL_REVIEW_ROUND - interval for Full Alignment Check (default: 5)
# STATE_ASK_CODEX_QUESTION - "true" or "false" (v1.6.5+)
# STATE_AGENT_TEAMS - "true" or "false"
# STATE_MAINLINE_STALL_COUNT - consecutive stalled/regressed implementation rounds
# STATE_LAST_MAINLINE_VERDICT - advanced/stalled/regressed/unknown
# STATE_DRIFT_STATUS - normal/replan_required
# Returns: 0 on success, 1 if file not found
# Note: For strict validation, use parse_state_file_strict() instead
parse_state_file() {
Expand All @@ -406,6 +423,9 @@ parse_state_file() {
STATE_FULL_REVIEW_ROUND="${STATE_FULL_REVIEW_ROUND:-5}"
STATE_ASK_CODEX_QUESTION="${STATE_ASK_CODEX_QUESTION:-true}"
STATE_AGENT_TEAMS="${STATE_AGENT_TEAMS:-false}"
STATE_MAINLINE_STALL_COUNT="${STATE_MAINLINE_STALL_COUNT:-0}"
STATE_LAST_MAINLINE_VERDICT="${STATE_LAST_MAINLINE_VERDICT:-$MAINLINE_VERDICT_UNKNOWN}"
STATE_DRIFT_STATUS="${STATE_DRIFT_STATUS:-$DRIFT_STATUS_NORMAL}"
# STATE_REVIEW_STARTED left as-is (empty if missing, to allow schema validation)

return 0
Expand Down Expand Up @@ -481,10 +501,116 @@ parse_state_file_strict() {
STATE_FULL_REVIEW_ROUND="${STATE_FULL_REVIEW_ROUND:-5}"
STATE_ASK_CODEX_QUESTION="${STATE_ASK_CODEX_QUESTION:-true}"
STATE_AGENT_TEAMS="${STATE_AGENT_TEAMS:-false}"
STATE_MAINLINE_STALL_COUNT="${STATE_MAINLINE_STALL_COUNT:-0}"
STATE_LAST_MAINLINE_VERDICT="${STATE_LAST_MAINLINE_VERDICT:-$MAINLINE_VERDICT_UNKNOWN}"
STATE_DRIFT_STATUS="${STATE_DRIFT_STATUS:-$DRIFT_STATUS_NORMAL}"

return 0
}

# Normalize mainline progress verdict to a safe enum.
# Usage: normalize_mainline_progress_verdict "ADVANCED"
normalize_mainline_progress_verdict() {
local verdict_lower
verdict_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')

case "$verdict_lower" in
"$MAINLINE_VERDICT_ADVANCED"|"$MAINLINE_VERDICT_STALLED"|"$MAINLINE_VERDICT_REGRESSED")
echo "$verdict_lower"
;;
*)
echo "$MAINLINE_VERDICT_UNKNOWN"
;;
esac
}

# Normalize drift status to a safe enum.
# Usage: normalize_drift_status "replan_required"
normalize_drift_status() {
local status_lower
status_lower=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')

case "$status_lower" in
"$DRIFT_STATUS_REPLAN_REQUIRED")
echo "$DRIFT_STATUS_REPLAN_REQUIRED"
;;
*)
echo "$DRIFT_STATUS_NORMAL"
;;
esac
}

# Extract "Mainline Progress Verdict" from Codex review content.
# Outputs one of: advanced, stalled, regressed, unknown
# Usage: extract_mainline_progress_verdict "$review_content"
extract_mainline_progress_verdict() {
local review_content="$1"
local verdict_line
local verdict_value

verdict_line=$(printf '%s\n' "$review_content" | grep -Ei 'Mainline Progress Verdict:[[:space:]]*(ADVANCED|STALLED|REGRESSED)([^A-Za-z]|$)' | tail -1 || true)
if [[ -z "$verdict_line" ]]; then
echo "$MAINLINE_VERDICT_UNKNOWN"
return
fi

verdict_value=$(printf '%s\n' "$verdict_line" | sed -E 's/.*Mainline Progress Verdict:[[:space:]]*(ADVANCED|STALLED|REGRESSED).*/\1/I')
normalize_mainline_progress_verdict "$verdict_value"
}

# Upsert simple YAML frontmatter fields in a state file.
# Values must not contain newlines.
# Usage: upsert_state_fields "/path/to/state.md" "field=value" "other=value"
upsert_state_fields() {
local state_file="$1"
shift

local temp_file="${state_file}.tmp.$$"

awk -v assignments="$*" '
BEGIN {
count = split(assignments, pairs, " ");
for (i = 1; i <= count; i++) {
split(pairs[i], kv, "=");
keys[kv[1]] = kv[2];
order[i] = kv[1];
}
separator_count = 0;
}
{
if ($0 == "---") {
separator_count++;
if (separator_count == 2) {
for (i = 1; i <= count; i++) {
key = order[i];
if (!(key in seen)) {
print key ": " keys[key];
seen[key] = 1;
}
}
}
print;
next;
}

handled = 0;
for (i = 1; i <= count; i++) {
key = order[i];
if ($0 ~ ("^" key ":")) {
print key ": " keys[key];
seen[key] = 1;
handled = 1;
break;
}
}

if (!handled) {
print;
}
}
' "$state_file" > "$temp_file" && mv "$temp_file" "$state_file"
}

# Detect review issues from codex review log file
# Returns:
# 0 - issues found (caller should continue review loop)
Expand Down Expand Up @@ -562,7 +688,7 @@ to_lower() {
}

# Check if a path (lowercase) matches a round file pattern
# Usage: is_round_file "$lowercase_path" "summary|prompt|todos"
# Usage: is_round_file "$lowercase_path" "summary|prompt|todos|contract"
is_round_file_type() {
local path_lower="$1"
local file_type="$2"
Expand All @@ -579,7 +705,7 @@ extract_round_number() {
filename_lower=$(to_lower "$filename")

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

# Check if a file is in the allowlist for the active loop
Expand Down Expand Up @@ -643,6 +769,21 @@ You cannot modify finalize-state.md. This file is managed by the loop system dur
load_and_render_safe "$TEMPLATE_DIR" "block/finalize-state-file-modification.md" "$fallback"
}

# Standard message for blocking round contract access during Finalize Phase
# Usage: finalize_contract_blocked_message "read"
finalize_contract_blocked_message() {
local action="$1"
local fallback="# Finalize Contract Access Blocked

There is no active round contract during the Finalize Phase.

Do not {{ACTION}} historical round contract files.
Use finalize-summary.md for finalize-only notes and goal-tracker.md for current state."

load_and_render_safe "$TEMPLATE_DIR" "block/finalize-contract-access.md" "$fallback" \
"ACTION=$action"
}

# Standard message for blocking summary file modifications via Bash
# Usage: summary_bash_blocked_message "$correct_summary_path"
summary_bash_blocked_message() {
Expand Down Expand Up @@ -671,6 +812,79 @@ is_goal_tracker_path() {
echo "$path_lower" | grep -qE 'goal-tracker\.md$'
}

# Extract the immutable section from a goal-tracker content stream.
# Supports both current trackers (with --- separator) and older trackers
# that jump directly from IMMUTABLE SECTION to MUTABLE SECTION.
extract_goal_tracker_immutable_from_stream() {
awk '
/^## IMMUTABLE SECTION[[:space:]]*$/ { capture=1 }
capture && /^## MUTABLE SECTION[[:space:]]*$/ { exit }
capture && /^---[[:space:]]*$/ { exit }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse full immutable section before mutable boundary

goal_tracker_mutable_update_allowed relies on extract_goal_tracker_immutable_from_stream, but this extractor exits on any standalone --- after ## IMMUTABLE SECTION; if the immutable content copied from a plan includes a horizontal rule, the comparison truncates early and edits below that line are no longer protected. In that case, Write/Edit validators can incorrectly accept changes to the immutable portion of goal-tracker.md, defeating the round>0 immutability guard.

Useful? React with 👍 / 👎.

capture { print }
'
}

# Extract the immutable section from an on-disk goal-tracker file.
# Usage: extract_goal_tracker_immutable_from_file "/path/to/goal-tracker.md"
extract_goal_tracker_immutable_from_file() {
local tracker_file="$1"
if [[ ! -f "$tracker_file" ]]; then
return 1
fi
extract_goal_tracker_immutable_from_stream < "$tracker_file"
}

# Extract the immutable section from an in-memory goal-tracker string.
# Usage: extract_goal_tracker_immutable_from_text "$content"
extract_goal_tracker_immutable_from_text() {
local tracker_content="$1"
printf '%s' "$tracker_content" | extract_goal_tracker_immutable_from_stream
}

# Check whether a proposed goal-tracker update preserves the immutable section.
# Usage: goal_tracker_mutable_update_allowed "/path/to/current.md" "$new_content"
goal_tracker_mutable_update_allowed() {
local tracker_file="$1"
local updated_content="$2"

local current_immutable=""
local updated_immutable=""
current_immutable=$(extract_goal_tracker_immutable_from_file "$tracker_file" 2>/dev/null || true)
updated_immutable=$(extract_goal_tracker_immutable_from_text "$updated_content" 2>/dev/null || true)

[[ -n "$current_immutable" ]] || return 1
[[ "$current_immutable" == "$updated_immutable" ]]
}

# Render the post-edit contents for a literal Edit operation.
# Returns non-zero if the edit preview cannot be produced.
# Usage: preview_edit_result "/path/to/file" "$old_string" "$new_string" "true|false"
preview_edit_result() {
local file_path="$1"
local old_string="$2"
local new_string="$3"
local replace_all="${4:-false}"

command -v perl >/dev/null 2>&1 || return 1

FILE_PATH="$file_path" \
OLD_STRING="$old_string" \
NEW_STRING="$new_string" \
REPLACE_ALL="$replace_all" \
perl -0pe '
BEGIN {
$old = $ENV{"OLD_STRING"};
$new = $ENV{"NEW_STRING"};
$replace_all = $ENV{"REPLACE_ALL"} eq "true";
}
if ($replace_all) {
s/\Q$old\E/$new/g;
} else {
s/\Q$old\E/$new/;
}
' "$file_path"
}

# Check if a path (lowercase) targets state.md
is_state_file_path() {
local path_lower="$1"
Expand Down Expand Up @@ -1275,17 +1489,24 @@ command_modifies_file() {
}

# Standard message for blocking goal-tracker modifications after Round 0
# Usage: goal_tracker_blocked_message "$current_round" "$summary_file_path"
# Usage: goal_tracker_blocked_message "$current_round" "$correct_goal_tracker_path"
goal_tracker_blocked_message() {
local current_round="$1"
local summary_file="$2"
local fallback="# Goal Tracker Modification Blocked (Round {{CURRENT_ROUND}})
local correct_path="$2"
local fallback="# Goal Tracker Update Blocked (Round {{CURRENT_ROUND}})

After Round 0, you may update only the **MUTABLE SECTION** of the active goal tracker.

Use Write or Edit on: {{CORRECT_PATH}}

After Round 0, only Codex can modify the Goal Tracker. Include a Goal Tracker Update Request in your summary: {{SUMMARY_FILE}}"
Rules:
- Keep the **IMMUTABLE SECTION** unchanged
- Do not modify `goal-tracker.md` via Bash
- Do not write to an old loop session's tracker"

load_and_render_safe "$TEMPLATE_DIR" "block/goal-tracker-modification.md" "$fallback" \
"CURRENT_ROUND=$current_round" \
"SUMMARY_FILE=$summary_file"
"CORRECT_PATH=$correct_path"
}

# End the loop by renaming state.md to indicate exit reason
Expand Down
Loading
Loading