diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index 4138655e83..3e501f8416 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -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, @@ -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 @@ -516,7 +520,7 @@ def render_state_markdown( ## Objective -{objective} +{objective_block} ## Authority Sources diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 32ec182d90..31cace85c4 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -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, @@ -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("# ")), "", diff --git a/loopx/control_plane/goals/active_state_metadata.py b/loopx/control_plane/goals/active_state_metadata.py index d65b7dc286..38af65b764 100644 --- a/loopx/control_plane/goals/active_state_metadata.py +++ b/loopx/control_plane/goals/active_state_metadata.py @@ -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 = "" +OBJECTIVE_REGION_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("---"): diff --git a/loopx/control_plane/projects/registry.py b/loopx/control_plane/projects/registry.py index b0dd31bcab..e78a6f4b5d 100644 --- a/loopx/control_plane/projects/registry.py +++ b/loopx/control_plane/projects/registry.py @@ -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") @@ -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 @@ -142,7 +146,7 @@ def bullets(items: list[str], *, empty: str) -> str: ## Objective -{objective} +{objective_block} ## Acceptance diff --git a/loopx/control_plane/todos/machine_region.py b/loopx/control_plane/todos/machine_region.py index 381daf74b2..b3b6f4f598 100644 --- a/loopx/control_plane/todos/machine_region.py +++ b/loopx/control_plane/todos/machine_region.py @@ -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 = "