Skip to content
Closed
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
6 changes: 5 additions & 1 deletion loopx/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from .control_plane.runtime.time import now_local_iso
from .control_plane.runtime.public_safety import public_safe_compact_text
from .control_plane.goals.active_state_metadata import render_objective_block
from .control_plane.todos.active_state_editing import (
TODO_SECTION_HEADINGS,
atomic_write_state_text,
Expand Down Expand Up @@ -504,6 +505,9 @@ def render_state_markdown(
handoff_mode_line = (
f"handoff_mode: {handoff_mode}\n" if handoff_mode != HANDOFF_MODE_LEGACY else ""
)
# The objective is user prose. Isolate it so a fenced or commented example
# inside it cannot hide the generated Todo sections below.
objective_block = render_objective_block(objective)
state_text = f"""---
status: active
owner_mode: goal
Expand All @@ -516,7 +520,7 @@ def render_state_markdown(

## Objective

{objective}
{objective_block}

## Authority Sources

Expand Down
6 changes: 5 additions & 1 deletion loopx/chat_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from .chat_ssh_source_api import SshSourceRequestMixin
from .chat_store import ChatSessionStore
from .capabilities.manager_runtime import manager_runtime_capability_projection
from .control_plane.goals.active_state_metadata import read_objective_text
from .control_plane.status.ssh_host_catalog import (
SSH_HOST_CATALOG_PATH,
ssh_host_catalog_payload,
Expand Down Expand Up @@ -149,7 +150,10 @@ def _goal_public_context(registry: dict[str, Any], goal: dict[str, Any]) -> dict
if state_path is not None and state_path.exists():
try:
state_text = state_path.read_text(encoding="utf-8")
objective = _active_state_section(state_text, "Objective")
objective = (
_compact_text(read_objective_text(state_text))
or _active_state_section(state_text, "Objective")
)
title_line = next(
(line[2:].strip() for line in state_text.splitlines() if line.startswith("# ")),
"",
Expand Down
42 changes: 42 additions & 0 deletions loopx/control_plane/goals/active_state_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,48 @@
"待办归档",
)

# Generated objective text is presentation data, never machine-owned Markdown.
# Objectives are arbitrary user prose, so they may contain fenced blocks, tilde
# fences, HTML comments, or text that merely looks like a Todo row. Writing one
# straight into the document body lets it open a construct that swallows the
# generated Todo sections below it. The markers below isolate that prose so
# readers keep treating it as text.
OBJECTIVE_REGION_BEGIN = "<!-- loopx:objective-v0 begin -->"
OBJECTIVE_REGION_END = "<!-- loopx:objective-v0 end -->"


def render_objective_block(objective: str) -> str:
"""Render generated objective text as an isolated, non-authoritative block.

The returned text preserves the objective verbatim between two marker
comments, so a direct objective readback still sees the prose while
Markdown readers never let it open a fence, open an HTML comment, or
contribute rows to the Todo sections that follow.
"""
text = str(objective or "").strip("\n")
if not text.strip():
return ""
return f"{OBJECTIVE_REGION_BEGIN}\n{text}\n{OBJECTIVE_REGION_END}"


def read_objective_text(state_text: str) -> str:
"""Read back the generated objective text from an isolated region.

Returns an empty string for legacy state documents written before the
objective region existed, so callers keep their previous fallback.
"""
lines = str(state_text or "").splitlines()
start = next(
(i for i, line in enumerate(lines) if line.strip() == OBJECTIVE_REGION_BEGIN), None
)
if start is None:
return ""
end = next(
(i for i in range(start + 1, len(lines)) if lines[i].strip() == OBJECTIVE_REGION_END),
len(lines),
)
return "\n".join(lines[start + 1 : end]).strip()


def parse_state_frontmatter(state_text: str) -> dict[str, str]:
if not state_text.startswith("---"):
Expand Down
6 changes: 5 additions & 1 deletion loopx/control_plane/projects/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ...paths import DEFAULT_RUNTIME_ROOT
from ...registry import atomic_write_json
from ...repository_identity import normalize_repository_identity
from ..goals.active_state_metadata import render_objective_block
from .contract import validate_project_record_bindings

PROJECT_KINDS = ("work", "personal")
Expand Down Expand Up @@ -129,6 +130,9 @@ def _state_markdown(
def bullets(items: list[str], *, empty: str) -> str:
return "\n".join(f"- {item}" for item in items) if items else f"- {empty}"

# The objective is user prose. Isolate it so a fenced or commented example
# inside it cannot hide the generated Todo sections below.
objective_block = render_objective_block(objective)
return f"""---
status: active
owner_mode: goal
Expand All @@ -142,7 +146,7 @@ def bullets(items: list[str], *, empty: str) -> str:

## Objective

{objective}
{objective_block}

## Acceptance

Expand Down
19 changes: 18 additions & 1 deletion loopx/control_plane/todos/machine_region.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
from dataclasses import dataclass

from .contract import TODO_TASK_PATTERN, parse_todo_metadata_line
from ..goals.active_state_metadata import TODO_ARCHIVE_HEADER_MARKERS, todo_role_for_heading
from ..goals.active_state_metadata import (
OBJECTIVE_REGION_BEGIN,
OBJECTIVE_REGION_END,
TODO_ARCHIVE_HEADER_MARKERS,
todo_role_for_heading,
)


TODO_REGION_PREFIX = "<!-- loopx:todo-region-v0 "
Expand Down Expand Up @@ -49,6 +54,7 @@ def visible_markdown_lines(lines: list[str]) -> frozenset[int]:
visible: set[int] = set()
fence: str | None = None
in_comment = False
in_objective = False
index = 0
if lines and lines[0].strip() == "---":
end = next((i for i in range(1, len(lines)) if lines[i].strip() in {"---", "..."}), None)
Expand All @@ -57,6 +63,17 @@ def visible_markdown_lines(lines: list[str]) -> frozenset[int]:
index = end + 1
while index < len(lines):
line = lines[index].rstrip("\r\n")
if in_objective:
# Generated objective prose is isolated: its fences and comments are
# text, not document structure. Real fences below still parse.
if line.strip() == OBJECTIVE_REGION_END:
in_objective = False
index += 1
continue
if line.strip() == OBJECTIVE_REGION_BEGIN:
in_objective = True
index += 1
continue
if fence is not None:
if re.fullmatch(r" {0,3}" + re.escape(fence[0]) + "{" + str(len(fence)) + r",}[ \t]*", line):
fence = None
Expand Down
131 changes: 131 additions & 0 deletions tests/control_plane/test_objective_todo_visibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Generated Todo sections stay readable when the objective is arbitrary prose.

Issue #4401: a goal objective is user-authored text, but it was written straight
into the generated state document. An objective that collapsed into a single
fence line, opened a tilde fence, opened an HTML comment, or merely looked like
a Todo row then hid or polluted the generated Todo sections below it.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from loopx.bootstrap import render_state_markdown
from loopx.chat_server import _active_state_section, _compact_text
from loopx.control_plane.goals.active_state_metadata import (
OBJECTIVE_REGION_BEGIN,
OBJECTIVE_REGION_END,
read_objective_text,
)
from loopx.control_plane.projects.registry import _state_markdown
from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source


USER_SECTION = "## User Todo / Owner Review Reading Queue"
AGENT_SECTION = "## Agent Todo"

OBJECTIVE_SECTION_LEGACY = "## Objective\n\nImplement and validate the task.\n\n## Agent Todo\n\n"

# Objectives that previously swallowed or polluted the generated Todo sections.
HOSTILE_OBJECTIVES = [
# start-goal collapses whitespace, so this becomes one unclosed fence line.
"```text Implement and validate the task. ```",
"~~~text Implement and validate the task. ~~~",
"<!-- Implement and validate the task.",
"- [ ] agent: example-only - implement and validate the task.",
]

# Objectives that must keep working exactly as before.
CONTROL_OBJECTIVES = [
"Implement and validate the task.",
"```text\nImplement and validate the task.\n```",
]


def _bootstrap_state(objective: str) -> str:
return render_state_markdown(
project=Path("/tmp/loopx-objective-visibility"),
goal_id="objective-example",
adapter_kind="read_only_project_map_v0",
objective=objective,
updated_at="2026-09-15T00:00:00+00:00",
goal_doc=None,
execution_profile=None,
)


def _registry_state(objective: str) -> str:
return _state_markdown(
project_id="project-example",
goal_id="objective-example",
objective=objective,
non_goals=[],
acceptance=[],
unknowns=[],
next_effect="Record the first project-specific adapter signal.",
stop_condition="The adapter signal is recorded.",
updated_at="2026-09-15T00:00:00+00:00",
)


STATE_BUILDERS = [_bootstrap_state, _registry_state]
BUILDER_IDS = ["bootstrap", "project-registration"]


@pytest.mark.parametrize("objective", HOSTILE_OBJECTIVES + CONTROL_OBJECTIVES)
@pytest.mark.parametrize("build", STATE_BUILDERS, ids=BUILDER_IDS)
def test_generated_todo_sections_stay_readable(build, objective: str) -> None:
items, _archive, sources = parse_todo_source(build(objective))
assert sources["user"] == USER_SECTION[3:]
assert sources["agent"] == AGENT_SECTION[3:]
# No objective prose may be adopted as authoritative Todo content.
assert all("example-only" not in str(item) for item in items["agent"])


@pytest.mark.parametrize("objective", HOSTILE_OBJECTIVES + CONTROL_OBJECTIVES)
@pytest.mark.parametrize("build", STATE_BUILDERS, ids=BUILDER_IDS)
def test_objective_text_is_preserved_verbatim(build, objective: str) -> None:
state = build(objective)
assert objective in state
assert OBJECTIVE_REGION_BEGIN in state
assert OBJECTIVE_REGION_END in state


@pytest.mark.parametrize("objective", HOSTILE_OBJECTIVES + CONTROL_OBJECTIVES)
@pytest.mark.parametrize("build", STATE_BUILDERS, ids=BUILDER_IDS)
def test_direct_objective_readback_recovers_the_goal_text(build, objective: str) -> None:
# The dashboard goal context reads the objective back from the isolated
# region, so fenced or commented prose is still recoverable as text.
assert read_objective_text(build(objective)) == objective
assert _compact_text(read_objective_text(build(objective))) == " ".join(objective.split())


def test_legacy_state_without_objective_region_falls_back() -> None:
legacy = f"---\nstatus: active\n---\n\n# Active Goal State\n\n{OBJECTIVE_SECTION_LEGACY}\n"
assert read_objective_text(legacy) == ""
# The pre-existing section scan still serves unmarked state documents.
assert _active_state_section(legacy, "Objective") == "Implement and validate the task."


def test_objective_markers_are_invisible_to_the_todo_reader() -> None:
state = _bootstrap_state(HOSTILE_OBJECTIVES[0])
body_start = state.index(OBJECTIVE_REGION_BEGIN)
body_end = state.index(OBJECTIVE_REGION_END)
assert body_start < body_end
# A real fence below an isolated objective still hides what it encloses.
fenced = (
state[: state.index(AGENT_SECTION)]
+ "```\n## Agent Todo\n- [ ] agent: hidden - should not be adopted.\n```\n\n"
+ state[state.index(AGENT_SECTION) :]
)
_items, _archive, sources = parse_todo_source(fenced)
assert sources["agent"] == AGENT_SECTION[3:]


def test_empty_objective_renders_without_markers() -> None:
state = _bootstrap_state("")
assert OBJECTIVE_REGION_BEGIN not in state
_items, _archive, sources = parse_todo_source(state)
assert sources["agent"] == AGENT_SECTION[3:]