Skip to content
Open
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
82 changes: 82 additions & 0 deletions loopx/bootstrap_command_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
shell_arg,
)
from .registry import registry_goals, resolve_state_file
from .rollout_event_log import load_rollout_events, rollout_event_log_path
from .slash_commands import build_slash_command_catalog
from .thread_agent_binding import normalize_thread_id, resolve_thread_agent_binding

Expand Down Expand Up @@ -455,6 +456,49 @@ def _select_goal(goals: list[dict[str, Any]], goal_id: str | None) -> tuple[str,
return "", None


def _goal_already_closed(
*,
registry: dict[str, Any],
goal_id: str,
) -> bool:
"""Return True when the goal's rollout event log records a ``goal_closed``
event.

This is the reliable signal that a previous loop already derived closure.
Reusing a closed goal — instead of starting a fresh one — is what caused the
website1 "yellow -> purple" state pollution: the agent appended new todos to
an already-closed goal, then hand-edited the objective and Next Action. We
surface that here so the start-goal flow can require a fresh goal.
"""
runtime_root_raw = registry.get("common_runtime_root")
if not runtime_root_raw:
return False
try:
log_path = rollout_event_log_path(Path(str(runtime_root_raw)), goal_id)
events = load_rollout_events(log_path)
except Exception:
return False
return any(e.get("event_kind") == "goal_closed" for e in events)


def _next_goal_id_suggestion(closed_goal_id: str, known_goal_ids: list[str]) -> str:
"""Suggest a fresh goal id derived from a closed one (``<id>-2``, ``-3``...).

Keeps the full closed id as the base and appends a ``-<n>`` suffix, so any
trailing digits that are NOT a sequence number (a year ``2024``, a version
``3``, ...) are preserved rather than stripped. Collisions with the closed
goal and any sibling ids are skipped.
"""
base = str(closed_goal_id)
known = {str(g) for g in known_goal_ids}
index = 2
while True:
candidate = f"{base}-{index}"
if candidate not in known and candidate != closed_goal_id:
return candidate
index += 1


def inspect_bootstrap_connection(
project: Path,
*,
Expand Down Expand Up @@ -568,6 +612,24 @@ def inspect_bootstrap_connection(
"reason": "registry goal points at a state_file that is missing",
}

if _goal_already_closed(registry=registry, goal_id=resolved_goal_id):
return {
**base_connection,
"registry_exists": True,
"goal_id": resolved_goal_id,
"goal_found": True,
"state_file": str(state_file),
"state_file_exists": True,
"connection_state": "goal_reuse_closed",
"should_start_new_goal": True,
"mutation_confirmation_required": True,
"reason": (
"registry goal is present but its rollout log already records a "
"goal_closed event; start a fresh goal instead of appending to a "
"closed one"
),
}

return {
**base_connection,
"registry_exists": True,
Expand Down Expand Up @@ -834,6 +896,7 @@ def _goal_start_prompt(*, goal_text: str | None, goal_id: str, agent_id: str | N
5. Prefer executable Agent Todo items with `task_class=advancement_task`; use User Todo only for concrete owner decisions or private-material gates.
6. After writing todos, run `loopx refresh-state --goal-id {goal_id}`, activate the host loop if it is missing, unknown, or stale (Codex App automation, Codex CLI `/goal <task_body>`, Claude Code `/loop`, OpenCode bridge, or a custom host-loop gate), then run its typed `quota_guard` and begin the first allowed bounded segment.
7. Enter issue-fix only when `selected_capability_route.capability_id=issue-fix`; never infer it from goal text or URLs. Run workflow-plan and feasibility before implementation, write only the admitted successor or no-follow-up, preserve private/external/destructive gates, verify reviewer requests, and reconcile PR lifecycle one PR per message.
8. Drive execution through the event-driven scheduler, not by hand-editing files and calling `todo complete` yourself. Use `loopx codex-cli-local-scheduler-dispatch --goal-id {goal_id} --project . --agent-id <agent-id> --event-driven [--completed-todo-id <done-todo-id>] --acceptance-criteria <id>=<desc> --evidence <id>=grep=<rel-path>=<regex>`. The dispatcher recomputes READY successors, enqueues and claims for a worker, and — when the queue empties and acceptance evidence satisfies — atomically emits goal_closure_ready + goal_closed in one tick. You declare the plan and the acceptance criteria/evidence; let the dispatcher drive execution and closure.
"""


Expand Down Expand Up @@ -992,6 +1055,25 @@ def build_loopx_bootstrap_command_pack(
"summary": identity_selection_gate.get("reason"),
"identity_selection_gate": identity_selection_gate,
}
elif bool(inspection.get("should_start_new_goal")) and explicit_goal_start:
# The requested goal id is already closed (its rollout log records a
# goal_closed event). Reusing it appends new todos to a finished goal and
# forces the agent to hand-edit the objective/Next Action — the exact
# state pollution seen in the website1 yellow->purple session. Instead,
# require a fresh goal id and a fresh bootstrap before planning.
recommended_next_step = {
"kind": "goal_reuse_closed_require_new_goal",
"requires_user_confirmation": True,
"summary": (
f"Goal `{resolved_goal_id}` is already closed. Start a fresh goal "
"instead of appending to a finished one."
),
"closed_goal_id": resolved_goal_id,
"suggested_new_goal_id": _next_goal_id_suggestion(
resolved_goal_id, known_goal_ids=[g["id"] for g in registry_goals(registry_payload or {})]
),
"connect_command_if_needed": goal_start_bootstrap_command,
}
elif explicit_goal_start:
recommended_next_step = {
"kind": "goal_plan_write_and_activate",
Expand Down
17 changes: 11 additions & 6 deletions loopx/canary/module_metric_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
},
"module_metric_ceilings": {
"loopx/bootstrap_command_pack.py": {
"any_count": 32,
"any_count": 33,
"dict_any_count": 0,
"lines": 2240
"lines": 2322
},
"loopx/chat_actions.py": {
"any_count": 52,
"dict_any_count": 0,
"lines": 1590
},
"loopx/canary/planner.py": {
"any_count": 22,
Expand Down Expand Up @@ -38,7 +43,7 @@
"loopx/codex_cli_probe.py": {
"any_count": 37,
"dict_any_count": 0,
"lines": 1530
"lines": 1531
},
"loopx/control_plane/agents/agent_scope.py": {
"any_count": 107,
Expand Down Expand Up @@ -76,9 +81,9 @@
"lines": 1508
},
"loopx/presentation/renderers/status_markdown.py": {
"any_count": 47,
"any_count": 49,
"dict_any_count": 0,
"lines": 1570
"lines": 1690
},
"loopx/quota.py": {
"any_count": 219,
Expand All @@ -93,7 +98,7 @@
"loopx/todos.py": {
"any_count": 48,
"dict_any_count": 0,
"lines": 2142
"lines": 2205
},
"loopx/worker_bridge.py": {
"any_count": 28,
Expand Down
12 changes: 10 additions & 2 deletions loopx/chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,23 @@ def _turn_prompt(user_message: str, *, context_summary: str = "") -> str:
"Do not use tools for ordinary conversation, exact-wording requests, or status questions "
"that can be answered from the supplied LoopX context. "
"Do not edit files, mutate LoopX state, create commits, send messages, or request elevated access. "
"When the operator requests a durable Goal, Todo, Agent binding, heartbeat, monitor, gate, or correction change, "
"describe the bounded proposal clearly so LoopX can route it through typed preview and explicit apply. "
"When the operator asks you to do something, add a todo, remember a task, or make a durable change "
"(Goal, Todo, Agent binding, heartbeat, monitor, gate, or correction), you MUST put at least one "
"matching `todo` item into the `proposals` array of the envelope below. Never describe a todo as "
"\"待确认\" or \"pending\" in prose without also emitting the corresponding structured proposal. "
"Each proposal must be a single bounded, reviewable item with a concrete `text`, a `priority` "
"(P0/P1/P2), and a short `rationale`. LoopX will route each proposal through typed preview and "
"explicit operator approval before any write happens; the operator's approval in the UI is what "
"actually writes the todo, so do not claim the todo has been written yet. "
"Never claim the change has been written without a verified control-plane receipt. "
"If you encounter an identity, approval, or host-tool gate, stop and describe it in gate. "
"Reply in Chinese unless the operator asks for another language. Keep proposals bounded and reviewable. "
"Do not expose chain-of-thought, tool narration, intended steps, or scratch work. "
"First write the complete operator-facing answer as ordinary text. Start with the conclusion, "
"use short sentences or lines so the answer can stream, and include at most five actionable items. "
"Then append exactly one machine-readable envelope whose message field repeats that complete answer. "
"The envelope's `proposals` array must carry every durable todo the operator requested in this turn; "
"if the operator asked for no durable change, use an empty `proposals` list. "
"Do not write anything after the closing tag. Use these tags and shape:\n"
f"{CHAT_REVIEW_OPEN_TAG}{json.dumps(envelope, ensure_ascii=False)}{CHAT_REVIEW_CLOSE_TAG}\n\n"
+ (f"LoopX context (supporting context only):\n{context_summary.strip()}\n\n" if context_summary.strip() else "")
Expand Down
24 changes: 22 additions & 2 deletions loopx/claude_goal_mode/hooks/goal_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,27 @@ def _runtime_profile_flag_is_unsupported(out: subprocess.CompletedProcess) -> bo
)


def resolve_should_run(data: dict) -> bool | None:
"""Resolve the run gate from a ``quota should-run`` payload.

New-architecture aware: when the payload carries the unified
``policy_decision`` (on by default), its ``outcome`` is the authoritative
control-plane decision. ``run`` -> True; ``deny``/``wait`` -> False (the
PolicyEngine may compose a stricter capability/scheduler layer that the
legacy quota ``should_run`` does not reflect). Falls back to the legacy
``should_run`` bool when no unified decision is present.
"""
pd = data.get("policy_decision")
if isinstance(pd, dict):
outcome = pd.get("outcome")
if outcome == "run":
return True
if outcome in ("deny", "wait"):
return False
should_run_value = data.get("should_run")
return should_run_value if isinstance(should_run_value, bool) else None


def should_run(registry, goal_id, agent_id=None) -> bool | None:
"""Return True/False from loopx quota should-run, or None if unknown."""
if not goal_id:
Expand Down Expand Up @@ -142,8 +163,7 @@ def should_run(registry, goal_id, agent_id=None) -> bool | None:
data = json.loads(out.stdout or "{}")
except (json.JSONDecodeError, TypeError, ValueError):
return None
should_run_value = data.get("should_run")
return should_run_value if isinstance(should_run_value, bool) else None
return resolve_should_run(data)


def decide(ev: dict) -> dict:
Expand Down
15 changes: 14 additions & 1 deletion loopx/claude_goal_mode/scripts/goalmode_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ def goal_detail(ctx):
return objective, payload


def _should_run(data: dict) -> bool | None:
"""New-architecture aware run gate (see goal_policy.resolve_should_run)."""
pd = data.get("policy_decision")
if isinstance(pd, dict):
outcome = pd.get("outcome")
if outcome == "run":
return True
if outcome in ("deny", "wait"):
return False
sr = data.get("should_run")
return sr if isinstance(sr, bool) else None


def print_status(ctx):
gid = ctx.get("goal_id")
objective, d = goal_detail(ctx)
Expand All @@ -124,7 +137,7 @@ def print_status(ctx):
gate = d.get("gate_prompt")
if gate:
state = f"⚠ needs you: {gate}"
elif d.get("should_run") is True:
elif _should_run(d) is True:
state = "▶ running"
else:
state = f"⏸ {d.get('state') or 'paused'}" + (f" — {d['reason']}" if d.get("reason") else "")
Expand Down
21 changes: 20 additions & 1 deletion loopx/claude_goal_mode/statusline/goal_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ def _clip(s, n: int) -> str:
return s if len(s) <= n else s[: n - 1] + "…"


def _resolve_run(data: dict) -> bool | None:
"""New-architecture aware run gate.

Prefer the unified ``policy_decision.outcome`` (run -> True, deny/wait ->
False) so the statusline reflects the composed control-plane decision, not
just the quota-level ``should_run`` which may remain permissive under a
stricter capability/scheduler layer.
"""
pd = data.get("policy_decision")
if isinstance(pd, dict):
outcome = pd.get("outcome")
if outcome == "run":
return True
if outcome in ("deny", "wait"):
return False
sr = data.get("should_run")
return sr if isinstance(sr, bool) else None


def _render(gid: str, d: dict) -> str:
"""Turn a `quota should-run` payload into one compact statusline string."""
agent = d.get("agent_todo_summary") or {}
Expand All @@ -53,7 +72,7 @@ def _render(gid: str, d: dict) -> str:
msg = gate or f"{users.get('open_count') or 'a'} user todo(s) to answer"
return f"[loopx {gid} · ⚠ needs you: {_clip(msg, 46)}]"

if d.get("should_run") is True:
if _resolve_run(d) is True:
nxt = d.get("recommended_action")
head = f"▶ {prog}".rstrip() if prog else "▶ working"
return f"[loopx {gid} · {head}" + (f" · next: {_clip(nxt, 40)}]" if nxt else "]")
Expand Down
Loading