diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 9ef4ef10ab..74bc1c4ccb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -159,6 +159,7 @@ const fieldCopy: Record = { executor_model: { label: "Model", description: "Optional model for the selected executor. Leave blank to keep the executor's own default." }, executor_reasoning_effort: { label: "Reasoning effort", description: "Optional reasoning effort for the selected executor. Leave blank to keep the executor's own default." }, count_unit: { label: "Review after", description: "Work Turns require accepted settlement; polls and retries do not count.", options: { completed_todos: "Completed Todos", effective_turns: "Settled work Turns" } }, + planning_horizon_goal: { label: "Show Goal in planning horizon", description: "Default off. Read the full original Goal and acceptance before choosing work. Does not change replan, stride or execution authority." }, stride_strategy: { label: "Stride strategy", description: "Structured work scope, route and validation proposals are required for enabled fresh Vision reviews. Does not grant execution authority.", options: { off: "Off", evidence_conditioned_v1: "Evidence-conditioned · v1" } }, count: { label: "Number between reviews", description: "Settled work Turns: 1–6; completed Todos: 1–5. Goal overrides take precedence over device defaults." }, allowed_domains: { label: "Allowed responsibility domains", description: "Enter one bounded, public-safe domain per line." }, @@ -194,6 +195,7 @@ const fieldCopy: Record = { executor_model: { label: "模型", description: "所选执行器使用的模型,可留空;留空表示沿用执行器自身的默认模型。" }, executor_reasoning_effort: { label: "推理档位", description: "所选执行器使用的推理档位,可留空;留空表示沿用执行器自身的默认档位。" }, count_unit: { label: "复核计数依据", description: "有效工作 Turn 须完成结算;轮询和重复重试不计数。", options: { completed_todos: "已完成 Todo", effective_turns: "已结算工作 Turn" } }, + planning_horizon_goal: { label: "在规划视野中显示 Goal", description: "默认关闭。选择工作前读取完整原目标与验收要求,不改变复盘、步幅策略或执行权限。" }, stride_strategy: { label: "步幅策略", description: "启用后,新的 Vision 路线复盘须提交工作大小、路线及验证范围的结构化提案,不授予执行权限。", options: { off: "关闭", evidence_conditioned_v1: "依据证据调整 · v1" } }, count: { label: "两次复核间的数量", description: "已结算工作 Turn 为 1–6,已完成 Todo 为 1–5;Goal 显式设置优先于设备默认值。" }, allowed_domains: { label: "允许的职责域", description: "每行填写一个有边界、可公开的职责域。" }, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx index 24192a293c..73eda04803 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-capability-settings.tsx @@ -379,8 +379,8 @@ function CapabilityCatalog({ callbacks, catalog, goalId, loading, notification,

: null} {selected.effective_value_policy === "goal_stride_over_live_review_cadence" ?

{locale === "zh-CN" - ? `复盘周期沿用${selected.machine_current ? "设备" : "产品"}默认;步幅策略由本 Goal 设置。` - : `Review interval follows ${selected.machine_current ? "device" : "product"} defaults; this Goal sets the stride strategy.`} + ? `复盘周期沿用${selected.machine_current ? "设备" : "产品"}默认;规划选项由本 Goal 设置。` + : `Review interval follows ${selected.machine_current ? "device" : "product"} defaults; this Goal sets the planning options.`}

: null} tuple[str, str, int]: def _replan_receipt(self) -> dict[str, int | str]: key, _, value = self._replan_configuration() return {key: value, **({"stride_strategy": self.stride_strategy} - if self.stride_strategy is not None else {})} + if self.stride_strategy is not None else {}), + **({"planning_horizon_goal": self.planning_horizon_goal} + if self.planning_horizon_goal is not None else {})} @staticmethod def name() -> str: @@ -422,6 +431,9 @@ async def _prepare_phase( key, option, expected = self._replan_configuration() stride_args = (["--execution-stride-strategy", self.stride_strategy] if self.stride_strategy is not None else []) + if self.planning_horizon_goal is not None: + stride_args.append("--execution-planning-horizon-goal" if self.planning_horizon_goal + else "--no-execution-planning-horizon-goal") pending = await environment.exec( command=f"test -e {_LOOPX_RUNTIME}/benchmark-pending-turn.json" ) @@ -502,6 +514,9 @@ async def _prepare_phase( raise RuntimeError( f"replan cadence readback mismatch for {key}: expected {expected}, got {configured!r}" ) + actual_horizon = configured_state.get("execution_profile", {}).get("planning_horizon_goal", False) + if actual_horizon != (self.planning_horizon_goal or False): + raise RuntimeError("planning_horizon_goal readback mismatch") strategy = configured_state.get("execution_profile", {}).get("stride_strategy", "off") if strategy != (self.stride_strategy or "off"): raise RuntimeError( diff --git a/benchmark/runtime/sforge.py b/benchmark/runtime/sforge.py index 0f6b139996..f410678ae1 100644 --- a/benchmark/runtime/sforge.py +++ b/benchmark/runtime/sforge.py @@ -95,6 +95,7 @@ def __init__(self, config, *, profile: str, cwd: str, replan_after_turns: int | None = None, replan_after_todos: int | None = None, stride_strategy: str | None = None, + planning_horizon_goal: bool | None = None, checkpoint_request_path: str | None = None, checkpoint_request: bytes | None = None, warm_start=None): @@ -143,6 +144,12 @@ def __init__(self, config, *, profile: str, cwd: str, if stride_strategy is not None: from loopx.control_plane.work_items.stride_policy_codec import normalize_stride_strategy stride_strategy = normalize_stride_strategy(stride_strategy) + if planning_horizon_goal is not None: + if not isinstance(planning_horizon_goal, bool): + raise ValueError("planning_horizon_goal must be a boolean") + if not profile.startswith("heartbeat-"): + raise ValueError("planning_horizon_goal requires a heartbeat profile") + self.planning_horizon_goal = planning_horizon_goal self.stride_strategy = stride_strategy self.checkpoint_request_path = None self.checkpoint_request = checkpoint_request @@ -265,6 +272,7 @@ def _install_worker(self, backend, handle, log_dir, logger): replan_after_turns=self.replan_after_turns, replan_after_todos=self.replan_after_todos, stride_strategy=self.stride_strategy, + planning_horizon_goal=self.planning_horizon_goal, checkpoint_request_path=self.checkpoint_request_path, checkpoint_request=self.checkpoint_request, task_objective=self.task_objective, diff --git a/benchmark/tests/test_edgebench_report.py b/benchmark/tests/test_edgebench_report.py index 97e1c965c2..31e39e5a7f 100644 --- a/benchmark/tests/test_edgebench_report.py +++ b/benchmark/tests/test_edgebench_report.py @@ -625,3 +625,32 @@ def build(repository): return data _publish_and_recheck(tmp_path, build) + + +@pytest.mark.parametrize("enabled", [None, False, True]) +def test_report_preserves_horizon_opt_in_and_historical_absence(trial, enabled): + runs, selection, output, _ = trial + for name in ("runtime-receipt", "worker-profile"): + path = runs / "run" / "case" / (name + ".json") + value = json.loads(path.read_text()) + if enabled is not None: + value["planning_horizon_goal"] = enabled + write(path, value) + export(runs, selection, output, observed_at="2026-01-02T00:00:00+00:00") + config = json.loads((output / "settings/run.json").read_text()) + for field in ("runner", "worker_profile"): + assert config[field].get("planning_horizon_goal") is enabled + assert ("planning_horizon_goal" in config[field]) is (enabled is not None) + + +@pytest.mark.parametrize("receipt,profile", [(True, False), (True, None), ("true", "true"), (True, 1)]) +def test_report_rejects_unbound_horizon_setting(trial, receipt, profile): + runs, selection, output, _ = trial + for name, enabled in (("runtime-receipt", receipt), ("worker-profile", profile)): + path = runs / "run" / "case" / (name + ".json") + value = json.loads(path.read_text()) + if enabled is not None: + value["planning_horizon_goal"] = enabled + write(path, value) + with pytest.raises(ValueError, match="planning_horizon_goal"): + export(runs, selection, output, observed_at="2026-01-02T00:00:00+00:00") diff --git a/benchmark/tests/test_sforge_runtime.py b/benchmark/tests/test_sforge_runtime.py index cf91fbbe05..b5f05f6193 100644 --- a/benchmark/tests/test_sforge_runtime.py +++ b/benchmark/tests/test_sforge_runtime.py @@ -462,7 +462,8 @@ def test_planned_sforge_entry_rejects_unsupported_profiles(profile, monkeypatch) @pytest.mark.parametrize("entry", ["seeded-todo", "loopx-planned"]) -def test_sforge_command_preserves_native_timing_and_planning_boundary(tmp_path, monkeypatch, entry): +@pytest.mark.parametrize("horizon", [None, False, True]) +def test_sforge_command_preserves_native_timing_and_planning_boundary(tmp_path, monkeypatch, entry, horizon): pytest.importorskip("sforge") pytest.importorskip("harbor") from sforge.harness.config import SForgeConfig @@ -472,9 +473,11 @@ async def installed(self, environment): pass monkeypatch.setattr(BenchmarkCodex, "install", installed) worker = SForgeWorker(SForgeConfig(agent_model="fixture", agent_effort="xhigh"), - profile="heartbeat-explore", cwd="/task", task_entry=entry) + profile="heartbeat-explore", cwd="/task", task_entry=entry, planning_horizon_goal=horizon) worker.install_stop_hook(None, None, tmp_path, None) assert worker.runtime.execution.task_entry == entry + assert worker.runtime.planning_horizon_goal is horizon + assert worker.runtime.execution.turn_envelope is False worker.prepared = True # Bootstrap is separately covered through real CLI fixtures. first = worker.format_run_cmd("/task.md") resumed = worker.format_run_cmd("/task.md", resume=True) @@ -482,7 +485,10 @@ async def installed(self, environment): assert ("benchmark.runtime.sforge_entry" in first) is (entry == "loopx-planned") assert "test -f /opt/loopx-benchmark/control/phase-deadline ||" in first assert first.index("export LOOPX_PHASE_DEADLINE_EPOCH") < first.index("timeout --signal") - assert json.loads((tmp_path / "worker-profile.json").read_text())["task_entry"] == entry + profile = json.loads((tmp_path / "worker-profile.json").read_text()) + assert profile["task_entry"] == entry + assert profile.get("planning_horizon_goal") is horizon + assert ("planning_horizon_goal" in profile) is (horizon is not None) @pytest.mark.parametrize('profile', ['heartbeat-resume', 'heartbeat-explore']) @pytest.mark.parametrize('enabled', [False, True]) @pytest.mark.parametrize('cadence', [None, 2, 6]) @@ -656,6 +662,8 @@ def test_invalid_checkpoint_declaration_rejects_before_trial_registration(tmp_pa @pytest.mark.parametrize("cadence_args,field,count", [ ([], "replan_after_effective_turns", 6), + (["--planning-horizon-goal"], "replan_after_effective_turns", 6), + (["--no-planning-horizon-goal"], "replan_after_effective_turns", 6), (["--replan-after-turns", "2"], "replan_after_effective_turns", 2), (["--replan-after-todos", "3"], "replan_after_completed_todos", 3), ]) @@ -693,6 +701,9 @@ def stop_before_solver(**kwargs): assert kwargs["timeout"] == kwargs["config"].agent_timeout == expected assert kwargs["eval_interval"] == interval agent = kwargs["agent"] + horizon = (True if "--planning-horizon-goal" in cadence_args else + False if "--no-planning-horizon-goal" in cadence_args else None) + assert agent.planning_horizon_goal is horizon assert "total trial budget" not in agent.workspace_instructions assert agent.task_objective == "fixture task" assert agent.task_objective in agent.feedback_prompt @@ -713,6 +724,10 @@ def stop_before_solver(**kwargs): assert receipt["timeout_seconds"] == expected assert receipt["eval_interval"] == interval assert receipt[field] == count + horizon = (True if "--planning-horizon-goal" in cadence_args else + False if "--no-planning-horizon-goal" in cadence_args else None) + assert receipt.get("planning_horizon_goal") is horizon + assert ("planning_horizon_goal" in receipt) is (horizon is not None) other = ("replan_after_completed_todos" if field == "replan_after_effective_turns" else "replan_after_effective_turns") assert other not in receipt diff --git a/benchmark/tests/test_shared_codex_runtime.py b/benchmark/tests/test_shared_codex_runtime.py index ed41f587cd..792eefb418 100644 --- a/benchmark/tests/test_shared_codex_runtime.py +++ b/benchmark/tests/test_shared_codex_runtime.py @@ -935,3 +935,34 @@ def test_toolface_qualification_captures_first_request_and_rejects_empty(tmp_pat toolface.qualify_toolface( set(), codex_bin=fake, model="fixture-model", timeout_sec=30, extra_env={"FAKE_DIE": "1"}) + + +@pytest.mark.parametrize("requested,actual", [(True, False), (False, True), (None, True)]) +def test_horizon_setting_must_match_restored_goal_before_work(tmp_path, monkeypatch, requested, actual): + pytest.importorskip("harbor") + from benchmark.runtime.harbor import BenchmarkCodex + agent = BenchmarkCodex(logs_dir=tmp_path, model_name="fixture", planning_horizon_goal=requested) + async def prepared(*args, **kwargs): + return True + async def no_pending(**kwargs): + return SimpleNamespace(return_code=1) + async def wrong_setting(*args, **kwargs): + return {"after": {"execution_profile": { + "replan_after_effective_turns": 6, "planning_horizon_goal": actual, + }}} + monkeypatch.setattr(agent, "_registry_exists", prepared) + monkeypatch.setattr(agent, "_write_task_document", prepared) + monkeypatch.setattr(agent, "_loopx", wrong_setting) + with pytest.raises(RuntimeError, match="planning_horizon_goal readback mismatch"): + asyncio.run(agent._prepare_phase(SimpleNamespace(exec=no_pending), "Synthetic task", cwd=str(tmp_path))) + + +@pytest.mark.parametrize("settings", [ + {"planning_horizon_goal": "true"}, {"planning_horizon_goal": 1}, + {"execution_mode": "plain", "planning_horizon_goal": False}, +]) +def test_horizon_setting_rejects_non_boolean_and_non_loopx_mode(tmp_path, settings): + pytest.importorskip("harbor") + from benchmark.runtime.harbor import BenchmarkCodex + with pytest.raises(ValueError, match="planning_horizon_goal"): + BenchmarkCodex(logs_dir=tmp_path, model_name="fixture", **settings) diff --git a/benchmark/tests/test_task_entry.py b/benchmark/tests/test_task_entry.py index 8a72e5887d..921c47a9f9 100644 --- a/benchmark/tests/test_task_entry.py +++ b/benchmark/tests/test_task_entry.py @@ -698,6 +698,8 @@ def test_sforge_planning_failed_host_has_no_execution_handoff(planning_env): @pytest.mark.parametrize("settings,field,value", [ ({}, "replan_after_effective_turns", 6), + ({"planning_horizon_goal": True}, "replan_after_effective_turns", 6), + ({"planning_horizon_goal": False}, "replan_after_effective_turns", 6), ({"replan_after_turns": 2}, "replan_after_effective_turns", 2), ({"replan_after_todos": 3}, "replan_after_completed_todos", 3), ({"stride_strategy": "off"}, "replan_after_effective_turns", 6), @@ -745,5 +747,8 @@ async def no_pending(**kwargs): assert other not in profile strategy = settings.get("stride_strategy") assert profile.get("stride_strategy", "off") == (strategy or "off") - assert agent._replan_receipt() == {field: value, **({"stride_strategy": strategy} + horizon = settings.get("planning_horizon_goal") + assert profile.get("planning_horizon_goal", False) == (horizon or False) + assert agent._replan_receipt() == {field: value, + **({"planning_horizon_goal": horizon} if horizon is not None else {}), **({"stride_strategy": strategy} if strategy is not None else {})} diff --git a/docs/reference/protocols/quota-planning-horizon-v0.md b/docs/reference/protocols/quota-planning-horizon-v0.md index 651e40874d..07432d40b8 100644 --- a/docs/reference/protocols/quota-planning-horizon-v0.md +++ b/docs/reference/protocols/quota-planning-horizon-v0.md @@ -3,9 +3,11 @@ `quota_planning_horizon_v0` is the bounded, read-only context that lets an agent reason beyond one locally selected Todo without inlining LoopX's full Todo store or task graph. It is present on the default `quota should-run` path -only when the current selected work has typed lineage, waiting/blocking, -scheduled-observation, or goal-acceptance context. Flat runnable alternatives -remain solely in `action_portfolio`, avoiding a duplicate hot-path view. +when a planning choice has strategic context. The optional `planning_horizon_goal` +setting is **off by default**. When enabled it carries the current `core_goal` +before work selection, including for a single local Todo or flat backlog. +When disabled, the existing strategic-context condition and projection are +unchanged. Flat runnable alternatives remain owned by `action_portfolio`. The problem it addresses is narrower than scheduling. A selected regression gate can be locally runnable while the strategic path also contains a facts @@ -35,6 +37,48 @@ not silently replace the selected Todo. authorities. Planning-horizon items are context until the agent performs the existing explicit selection/re-entry flow. +The Goal lens reuses the replan source reader and TypeScript `projectCoreGoal` +projection: active-state Objective first, registered objective as fallback, +explicit missing state, and the existing separately scoped acceptance contract. +It does not persist another Goal, promote an Agent vision or Todo into the +original objective, enable stride, or change action eligibility. When +available, `core_goal.read_action` reads the same complete Goal state, with the +source path shell-quoted. A corrected source is read on the next planning +projection; no copied objective needs synchronization. + +This extends planning context only after explicit opt-in. Existing Todo selection, +claim/lease, admission, replan precedence and settlement authority remain +unchanged. Goal text is scoped decision input; public export remains a separate +boundary. The complete original objective is not shortened to the Todo display +budget. Transport-size warnings remain visible rather than discarding its tail. + +## Enable, Read Back And Disable + +The existing **Goal review and stride** capability editor in Settings → +Capabilities exposes **Show Goal in planning horizon** at machine and Goal scope. +Use its preview, apply and readback flow. Its optional boolean is stored in the +existing `execution_profile.planning_horizon_goal`; no new capability or Goal +store is introduced. The v2 machine review configuration accepts the same +optional boolean; omission preserves its previous shape and disabled default. +As with stride, a Goal with an explicit review cadence uses its own configuration; +a sparse Goal switch inherits the live interval. An explicit false overrides a +machine opt-in. Clearing a Goal override restores the applicable inherited value. + +```sh +loopx configure-goal --goal-id example --execution-planning-horizon-goal --execute +loopx configure-goal --goal-id example --format json +loopx quota should-run --goal-id example --agent-id researcher --format json +loopx configure-goal --goal-id example --no-execution-planning-horizon-goal --execute +``` + +The quota readback contains `planning_horizon.core_goal` only when this projection +is enabled and a preselection planning projection is applicable. It is not added +to receipt-bound replays or ineligible work merely to repeat the Goal. Disabling +skips the extra Goal-document read. Replan continues to read its existing Goal +context independently. TurnEnvelope, stride, execution permission and quota are +not enabled by this setting. Context/signature changes are observable input +changes, not evidence of improved model decisions. + ## Shape ```json @@ -44,6 +88,12 @@ not silently replace the selected Todo. "goal_id": "loopx-meta", "agent_id": "codex-main-control", "selected_todo_id": "todo_regression_gate", + "core_goal": { + "objective": "Deliver the complete accepted outcome.", + "objective_source": "active_state", + "objective_missing": false, + "acceptance_contract": null + }, "selection_contract": { "selected_todo_authority": "$.selected_todo", "action_choice_authority": "$.action_portfolio", @@ -179,6 +229,11 @@ A conforming implementation must prove: - portfolio, horizon, and agent-Todo detail consume the same typed inventory; - task graph consumes the same canonical Todo selector, plus terminal evidence rows, without acquiring agent-scoped selection or claim authority; +- omitted/false settings retain the prior horizon and skip extra Goal reads; +- when enabled, the same current Goal reaches preselection horizon and replan, with original + trailing constraints and source readback intact; +- missing, corrected and fallback sources stay explicit; scoped acceptance and + latest Agent vision do not replace the original Goal; - the horizon never changes `selected_todo` or action-portfolio eligibility; - runnable unclaimed work preserves `claim_required_before_work=true`; - typed successor/resume/route relations survive the default compact path; diff --git a/examples/personal-workspace-browser/replan-cadence.mjs b/examples/personal-workspace-browser/replan-cadence.mjs index dd21e56826..2ca9a00192 100644 --- a/examples/personal-workspace-browser/replan-cadence.mjs +++ b/examples/personal-workspace-browser/replan-cadence.mjs @@ -127,7 +127,7 @@ export const replanCadenceScenario = { || converted.effective_configuration.configuration.count_unit !== "effective_turns") { throw new Error("JSON-to-form save substituted the product-default interval"); } - await page.getByText("复盘周期沿用设备默认;步幅策略由本 Goal 设置。", { exact: true }).waitFor(); + await page.getByText("复盘周期沿用设备默认;规划选项由本 Goal 设置。", { exact: true }).waitFor(); await page.screenshot({ path: resolve(outputDir, "replan-cadence-json-form.png"), animations: "disabled" }); if (JSON.stringify(converted.current) !== JSON.stringify({ stride_strategy: "evidence_conditioned_v1" })) { throw new Error("JSON-to-form strategy-only save pinned a cadence"); @@ -148,6 +148,33 @@ export const replanCadenceScenario = { await page.getByRole("combobox", { name: "目标 Goal", exact: true }).selectOption("multi-agent-projection"); await page.getByRole("navigation", { name: "Goal 能力目录" }).getByRole("button", { name: /Goal 复盘与步幅/ }).click(); } + // The same real configuration owner supports the independent horizon + // switch without pinning cadence or changing stride. Unchecked remains off. + const horizonGoal = page.getByLabel(/^在规划视野中显示 Goal/); + assert.equal(await horizonGoal.isChecked(), false); + await horizonGoal.check(); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await applyGoalPreview(); + await reopenGoalCadence(); + assert.equal(await horizonGoal.isChecked(), true); + let horizonReadback = await readGoalCadence(); + assert.equal(horizonReadback.current.planning_horizon_goal, true); + assert.equal(horizonReadback.current.stride_strategy, "evidence_conditioned_v1"); + assert.equal(horizonReadback.current.count, undefined); + await page.screenshot({ path: resolve(outputDir, "planning-horizon-goal-enabled.png"), animations: "disabled" }); + await horizonGoal.uncheck(); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await applyGoalPreview(); + await reopenGoalCadence(); + assert.equal(await horizonGoal.isChecked(), false); + horizonReadback = await readGoalCadence(); + assert.equal(horizonReadback.current.planning_horizon_goal, false); + // Restore the sparse pre-existing scenario before its cadence transitions. + await page.getByRole("button", { name: "编辑 JSON", exact: true }).click(); + await page.locator("#goal-configuration-json").fill(JSON.stringify({stride_strategy: "evidence_conditioned_v1"})); + await page.getByRole("button", { name: "返回表单", exact: true }).click(); + await page.getByRole("button", { name: "预览变更", exact: true }).click(); + await applyGoalPreview(); await changeMachineCount(4); if ((await readGoalCadence()).effective_configuration.configuration.count !== 4) { throw new Error("Strategy-only Goal did not follow a later machine policy"); @@ -187,6 +214,7 @@ export const replanCadenceScenario = { await delayedReadback.started; assert.equal(await count.isDisabled(), true, "A committed write must keep the editor closed until its readback settles"); assert.equal(await strategy.isDisabled(), true); + assert.equal(await horizonGoal.isDisabled(), true); assert.equal(await page.getByRole("button", { name: "编辑 JSON", exact: true }).isDisabled(), true); assert.equal(await page.getByRole("button", { name: "预览变更", exact: true }).isDisabled(), true); const goalCatalog = page.getByRole("navigation", { name: "Goal 能力目录" }); @@ -223,6 +251,8 @@ export const replanCadenceScenario = { await unit.focus(); await page.keyboard.press("Tab"); await page.keyboard.press("Tab"); + assert.equal(await horizonGoal.evaluate((element) => element === document.activeElement), true, "Horizon switch is keyboard reachable"); + await page.keyboard.press("Tab"); if (!(await strategy.evaluate((element) => element === document.activeElement))) throw new Error("Strategy is not keyboard reachable"); await page.screenshot({ path: resolve(outputDir, "replan-cadence-goal-mobile.png"), animations: "disabled" }); if (await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1)) throw new Error("Cadence editor overflowed"); diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index 40b211c309..47813f3359 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -115,6 +115,8 @@ def capability_configuration_editor( _field("count", "Review interval", "number", minimum=1, maximum=MAX_EFFECTIVE_TURN_REPLAN_THRESHOLD, required=True, description="Effective Turns: 1–6; completed Todos: 1–5. Effective Turns require accepted work settlement. Retries and observation-only polls do not count."), + _field("planning_horizon_goal", "Show Goal in planning horizon", "boolean", + description="Default off. Include the full original Goal and acceptance before choosing work; replan Goal reading, stride and execution authority are unchanged."), _field("stride_strategy", "Stride strategy", "select", options=list(stride_strategy_options()), description="Off retains ordinary review. Evidence-conditioned v1 independently reviews work-slice size, route changes and validation scope; fresh Vision path reviews require a structured proposal, without automatic execution."), @@ -618,7 +620,8 @@ def _attach_effective_configuration(entry: dict[str, Any]) -> None: if ( entry["capability_id"] == "todo_replan_cadence" and goal_override is not None - and set(goal_override) == {"stride_strategy"} + and bool(goal_override) + and set(goal_override) <= {"stride_strategy", "planning_horizon_goal"} ): from .todo_replan_cadence.machine_defaults import ( cadence_configuration_value, @@ -633,8 +636,7 @@ def _attach_effective_configuration(entry: dict[str, Any]) -> None: else default_replan_cadence_configuration() ) goal_override = { - "count_unit": inherited_cadence["count_unit"], - "count": inherited_cadence["count"], + **inherited_cadence, **goal_override, } entry["effective_value_policy"] = "goal_stride_over_live_review_cadence" diff --git a/loopx/capabilities/todo_replan_cadence/goal_configuration.py b/loopx/capabilities/todo_replan_cadence/goal_configuration.py index 4267f92751..2290e46489 100644 --- a/loopx/capabilities/todo_replan_cadence/goal_configuration.py +++ b/loopx/capabilities/todo_replan_cadence/goal_configuration.py @@ -12,7 +12,7 @@ def configuration_summary(goal: Mapping[str, Any]) -> dict[str, Any] | None: profile = goal.get("execution_profile") if not isinstance(profile, Mapping): return None - strategy = {"stride_strategy": profile["stride_strategy"]} if "stride_strategy" in profile else {} + strategy = {key: profile[key] for key in ("stride_strategy", "planning_horizon_goal") if key in profile} if "replan_after_effective_turns" in profile: return { "count_unit": "effective_turns", diff --git a/loopx/capabilities/todo_replan_cadence/machine_defaults.py b/loopx/capabilities/todo_replan_cadence/machine_defaults.py index e0b171e481..53b126b4f2 100644 --- a/loopx/capabilities/todo_replan_cadence/machine_defaults.py +++ b/loopx/capabilities/todo_replan_cadence/machine_defaults.py @@ -36,7 +36,7 @@ def normalize_replan_cadence_configuration(raw: Mapping[str, Any]) -> dict[str, "count_unit": ReplanCadenceUnit.COMPLETED_TODOS.value, "count": normalize_completed_todo_replan_threshold(raw["completed_todos"]), } - if set(raw) not in ({"count_unit", "count"}, {"count_unit", "count", "stride_strategy"}): + if not {"count_unit", "count"} <= set(raw) or set(raw) - {"count_unit", "count", "stride_strategy", "planning_horizon_goal"}: raise ValueError("review cadence requires count_unit and count") try: unit = ReplanCadenceUnit(raw["count_unit"]) @@ -53,6 +53,10 @@ def normalize_replan_cadence_configuration(raw: Mapping[str, Any]) -> dict[str, if "stride_strategy" in raw: from ...control_plane.work_items.stride_policy_codec import normalize_stride_strategy result["stride_strategy"] = normalize_stride_strategy(raw["stride_strategy"]) + if "planning_horizon_goal" in raw: + if not isinstance(raw["planning_horizon_goal"], bool): + raise ValueError("planning_horizon_goal must be a boolean") + result["planning_horizon_goal"] = raw["planning_horizon_goal"] return result @@ -74,6 +78,8 @@ def normalize_todo_replan_cadence_machine_defaults( expected = {"schema_version", "count_unit", "count"} if version == TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_V2: expected.add("stride_strategy") + if "planning_horizon_goal" in raw: + expected.add("planning_horizon_goal") if set(raw) != expected: raise ValueError("review configuration fields do not match its schema version") return { @@ -177,6 +183,8 @@ def apply_todo_replan_cadence_machine_default( profile[field] = cadence["count"] if "stride_strategy" not in profile and cadence.get("stride_strategy", "off") != "off": profile["stride_strategy"] = cadence["stride_strategy"] + if "planning_horizon_goal" not in profile and "planning_horizon_goal" in cadence: + profile["planning_horizon_goal"] = cadence["planning_horizon_goal"] projected["execution_profile"] = profile return projected diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index 8388c08dbe..d9a333c6bf 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -195,7 +195,7 @@ def _goal_capability_options( if capability_id == "periodic_report": return {"clear_periodic_report_configuration": True} if capability_id == "todo_replan_cadence": - return {"clear_execution_replan_after_todos": True, "clear_execution_replan_after_turns": True, "clear_execution_stride_strategy": True} + return {"clear_execution_replan_after_todos": True, "clear_execution_replan_after_turns": True, "clear_execution_stride_strategy": True, "clear_execution_planning_horizon_goal": True} if capability_id == "pull_request_review": return {"clear_pull_request_review_configuration": True} if capability_id == "change_quality_qualification": @@ -208,7 +208,7 @@ def _goal_capability_options( config = dict(configuration) allowed: dict[str, set[str]] = { "goal_capability_organization": {"mode", "discovery_budget_minutes", "max_trials"}, - "todo_replan_cadence": {"completed_todos", "count_unit", "count", "stride_strategy"}, + "todo_replan_cadence": {"completed_todos", "count_unit", "count", "stride_strategy", "planning_horizon_goal"}, "multi_subagent": { "enabled", "max_children", @@ -244,19 +244,26 @@ def _goal_capability_options( if capability_id == "todo_replan_cadence": from .capabilities.todo_replan_cadence.machine_defaults import normalize_replan_cadence_configuration from .control_plane.work_items.stride_policy_codec import normalize_stride_strategy - if set(config) == {"stride_strategy"}: + horizon = {"clear_execution_planning_horizon_goal": True} + if "planning_horizon_goal" in config: + if not isinstance(config["planning_horizon_goal"], bool): + raise ValueError("planning_horizon_goal must be a boolean") + horizon = {"execution_planning_horizon_goal": config["planning_horizon_goal"]} + if config and set(config) <= {"stride_strategy", "planning_horizon_goal"}: # The capability editor submits the complete sparse value. Unlike # the CLI's single-field strategy patch, omission restores live # interval inheritance even when this Goal was previously pinned. return { - "execution_stride_strategy": normalize_stride_strategy(config["stride_strategy"]), + **horizon, + **({"execution_stride_strategy": normalize_stride_strategy(config["stride_strategy"])} + if "stride_strategy" in config else {}), "clear_execution_replan_after_turns": True, "clear_execution_replan_after_todos": True, } cadence = normalize_replan_cadence_configuration(config) option = ("execution_replan_after_turns" if cadence["count_unit"] == "effective_turns" else "execution_replan_after_todos") - return {option: cadence["count"], **({"execution_stride_strategy": cadence["stride_strategy"]} + return {option: cadence["count"], **horizon, **({"execution_stride_strategy": cadence["stride_strategy"]} if "stride_strategy" in cadence else {})} if capability_id == "periodic_report": return {"periodic_report_configuration": config} diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index d6ea55411a..01d93dc8ff 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -452,6 +452,8 @@ def handle_registry_admin_command( execution_turn_granularity=args.execution_turn_granularity, execution_replan_after_todos=args.execution_replan_after_todos, execution_replan_after_turns=args.execution_replan_after_turns, + execution_planning_horizon_goal=args.execution_planning_horizon_goal, + clear_execution_planning_horizon_goal=args.clear_execution_planning_horizon_goal, execution_stride_strategy=args.execution_stride_strategy, clear_execution_stride_strategy=args.clear_execution_stride_strategy, clear_execution_replan_after_turns=args.clear_execution_replan_after_turns, diff --git a/loopx/cli_commands/registry_admin_configure.py b/loopx/cli_commands/registry_admin_configure.py index 06b7692340..48300f2bcb 100644 --- a/loopx/cli_commands/registry_admin_configure.py +++ b/loopx/cli_commands/registry_admin_configure.py @@ -70,6 +70,15 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N "--clear-execution-replan-after-turns", action="store_true", help="Remove the Goal effective-Turn override and restore device/default inheritance.", ) + from argparse import BooleanOptionalAction + configure_goal_parser.add_argument( + "--execution-planning-horizon-goal", action=BooleanOptionalAction, default=None, + help="Include the full same-source Goal in preselection planning horizons. Default off; no selection or execution authority change.", + ) + configure_goal_parser.add_argument( + "--clear-execution-planning-horizon-goal", action="store_true", + help="Remove the Goal horizon override and restore review-configuration inheritance.", + ) from ..control_plane.work_items.stride_policy_codec import stride_strategy_options configure_goal_parser.add_argument( "--execution-stride-strategy", choices=stride_strategy_options(), diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index c7db8e3676..24901ced0b 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -440,6 +440,8 @@ def configure_goal( execution_replan_after_turns: int | None = None, execution_stride_strategy: str | None = None, clear_execution_stride_strategy: bool = False, + execution_planning_horizon_goal: bool | None = None, + clear_execution_planning_horizon_goal: bool = False, clear_execution_replan_after_turns: bool = False, clear_execution_replan_after_todos: bool = False, self_repair_enabled: bool | None = None, @@ -833,6 +835,8 @@ def configure_goal( replan_after_completed_todos=execution_replan_after_todos, replan_after_effective_turns=execution_replan_after_turns, stride_strategy=execution_stride_strategy, clear_stride_strategy=clear_execution_stride_strategy, + planning_horizon_goal=execution_planning_horizon_goal, + clear_planning_horizon_goal=clear_execution_planning_horizon_goal, clear_replan_after_effective_turns=clear_execution_replan_after_turns, clear_replan_after_completed_todos=clear_execution_replan_after_todos, ) diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index f81bda1c3b..d354005a94 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -810,6 +810,8 @@ def _planning_projections( capability_gate=prepared.capability_gate, blocked_priority_fallback=prepared.blocked_priority_fallback, goal_frontier_projection=prepared.goal_frontier_projection, + source_status=prepared.status_payload, + planning_horizon_goal=(prepared.project_asset.get("execution_profile") or {}).get("planning_horizon_goal", False), replan_selection_context=( { "has_turn_identity": bool(turn_instance_id), diff --git a/loopx/control_plane/work_items/action_portfolio.py b/loopx/control_plane/work_items/action_portfolio.py index f17e05cb47..5ad65201a6 100644 --- a/loopx/control_plane/work_items/action_portfolio.py +++ b/loopx/control_plane/work_items/action_portfolio.py @@ -54,9 +54,13 @@ def build_quota_planning_packet( blocked_priority_fallback: Mapping[str, Any] | None, goal_frontier_projection: Mapping[str, Any] | None, replan_selection_context: Mapping[str, Any] | None = None, + source_status: Mapping[str, Any] | None = None, + planning_horizon_goal: bool = False, ) -> dict[str, Any]: """Project every requested planning lens through one TypeScript request.""" + if not isinstance(planning_horizon_goal, bool): + raise ValueError("planning_horizon_goal must be a boolean") if not projection_enabled and not include_detail and replan_selection_context is None: return {} inventory_request = build_quota_planning_inventory_request( @@ -70,6 +74,12 @@ def build_quota_planning_packet( ) if inventory_request is None: return {} + from .replan_context_codec import goal_context_facts + + goal_facts = ( + goal_context_facts(source_status, goal_id, (agent_todo_summary or {}).get("goal_acceptance_contract")) + if planning_horizon_goal and source_status and source_status.get("registry") else None + ) try: projected = effect_runtime_result( "work_item.action_portfolio.project", @@ -81,6 +91,7 @@ def build_quota_planning_packet( "acceptance_gaps": _frontier_acceptance_gaps( goal_frontier_projection ), + **({"goal_facts": goal_facts} if goal_facts is not None else {}), **({"replan_selection_context": dict(replan_selection_context)} if replan_selection_context is not None else {}), }, diff --git a/loopx/control_plane/work_items/action_portfolio.ts b/loopx/control_plane/work_items/action_portfolio.ts index 436787f60a..12f18cc34a 100644 --- a/loopx/control_plane/work_items/action_portfolio.ts +++ b/loopx/control_plane/work_items/action_portfolio.ts @@ -324,6 +324,7 @@ function projectQuotaPlanningPacket(request: JsonObject): JsonObject { schema_version: PLANNING_HORIZON_REQUEST_SCHEMA_VERSION, planning_inventory: inventory, acceptance_gaps: request.acceptance_gaps, + ...(request.goal_facts === undefined ? {} : {goal_facts: request.goal_facts}), }); if (horizon !== null) projected.planning_horizon = horizon; } diff --git a/loopx/control_plane/work_items/planning_horizon.ts b/loopx/control_plane/work_items/planning_horizon.ts index 8f612afcd7..33891a9ef8 100644 --- a/loopx/control_plane/work_items/planning_horizon.ts +++ b/loopx/control_plane/work_items/planning_horizon.ts @@ -13,6 +13,7 @@ import { import { relationKey, todoRef, type PlanningInventoryRelation } from "./planning_relations.ts"; import type { JsonObject } from "../effect_program.ts"; +import { projectCoreGoal } from "./replan_context.ts"; export const PLANNING_HORIZON_SCHEMA_VERSION = "quota_planning_horizon_v0"; export const PLANNING_HORIZON_REQUEST_SCHEMA_VERSION = @@ -242,7 +243,8 @@ export function projectQuotaPlanningHorizon(value: unknown): JsonObject | null { item.todo_id !== selected.todo_id && strategicContextStates.has(item.planning_state) ); - if (!addsStrategicContext) { + const core = request.goal_facts === undefined ? null : projectCoreGoal(request.goal_facts); + if (!addsStrategicContext && core === null) { return null; } return { @@ -251,6 +253,7 @@ export function projectQuotaPlanningHorizon(value: unknown): JsonObject | null { goal_id: goalId, agent_id: agentId, selected_todo_id: selected.todo_id, + ...(core === null ? {} : {core_goal: core}), selection_contract: { selected_todo_authority: "$.selected_todo", action_choice_authority: "$.action_portfolio", diff --git a/loopx/control_plane/work_items/replan_context.ts b/loopx/control_plane/work_items/replan_context.ts index 3d6147fcf2..ed934c9e67 100644 --- a/loopx/control_plane/work_items/replan_context.ts +++ b/loopx/control_plane/work_items/replan_context.ts @@ -91,7 +91,7 @@ function evidenceFactsKey(row: JsonObject): string { return digest(facts); } -function coreGoal(value: unknown): JsonObject { +export function projectCoreGoal(value: unknown): JsonObject { const facts = value == null ? {} : requireJsonObject(value, "goal_facts"); const active = facts.active_state_objective; const registered = facts.registry_objective; @@ -102,7 +102,10 @@ function coreGoal(value: unknown): JsonObject { const acceptance = facts.acceptance_contract == null ? null : requireJsonObject(facts.acceptance_contract, "goal_acceptance_contract"); return {objective, objective_source: active ? "active_state" : registered ? "registry" : null, - objective_missing: objective === null, acceptance_contract: acceptance}; + objective_missing: objective === null, acceptance_contract: acceptance, + ...(facts.goal_read_action == null ? {} : { + read_action: requireNonEmptyString(facts.goal_read_action, "goal_facts.goal_read_action"), + })}; } /** Qualify recorded bindings using the settlement owner, without settling work. @@ -388,7 +391,7 @@ export function projectReplanContext(value: unknown): JsonObject { const selectedCoverage = new Set(chosen.flatMap(({row}) => row.progress_observation ? [String((row.progress_observation as JsonObject).fingerprint)] : [])); const ledger = [...coverage].filter(([key]) => selectedCoverage.has(key)).map(([, value]) => value); - const core = coreGoal(request.goal_facts); + const core = projectCoreGoal(request.goal_facts); const strideStrategy = normalizeStrideStrategy(request.stride_strategy); const exploreBasis = strideStrategy !== "off" ? projectStrideExploreBasis(request.explore_context, goal, agent) : null; const followthrough = agent && strideStrategy !== "off" diff --git a/loopx/control_plane/work_items/replan_context_codec.py b/loopx/control_plane/work_items/replan_context_codec.py index 5ab872f2d9..317c88ca04 100644 --- a/loopx/control_plane/work_items/replan_context_codec.py +++ b/loopx/control_plane/work_items/replan_context_codec.py @@ -111,7 +111,7 @@ def replan_evidence_rows(runs: Iterable[Mapping[str, Any]], *, goal_id: str, age return rows -def _goal_facts(status: Mapping[str, Any] | None, goal_id: str, acceptance: Mapping[str, Any] | None) -> dict[str, Any]: +def goal_context_facts(status: Mapping[str, Any] | None, goal_id: str, acceptance: Mapping[str, Any] | None) -> dict[str, Any]: """Read existing Goal sources; source precedence belongs to TypeScript.""" def goal_text(value: Any) -> str | None: if value is not None and not isinstance(value, str): @@ -130,6 +130,8 @@ def goal_text(value: Any) -> str | None: facts["goal_ref"] = {"goal_id": goal_id, "goal_instance_id": goal["goal_instance_id"]} facts["registry_objective"] = goal_text(goal.get("objective")) path = goal_state_path(goal) + if path: + facts["goal_read_action"] = "cat -- " + shlex.quote(str(path.resolve())) if path and path.exists(): state = path.read_text(encoding="utf-8") metadata, _ = split_state_frontmatter(state) @@ -177,7 +179,7 @@ def project_replan_context( **({"explore_context": explore_context} if explore_context is not None else {}), "evidence_ref": evidence_ref, "read_prefix": shlex.join(prefix), - "goal_facts": _goal_facts(source_status, goal_id, goal_acceptance_contract) if evidence_ref is None else {}, + "goal_facts": goal_context_facts(source_status, goal_id, goal_acceptance_contract) if evidence_ref is None else {}, "from_full_index": bool(source_status and source_status.get("registry") and source_status.get("runtime_root")), }, method="work_item.replan_context") except EffectRuntimeRejected as exc: diff --git a/loopx/execution_profile.py b/loopx/execution_profile.py index e2beb5e700..bed86f2439 100644 --- a/loopx/execution_profile.py +++ b/loopx/execution_profile.py @@ -154,6 +154,11 @@ def compact_execution_profile(value: Any) -> dict[str, Any]: if not isinstance(value, dict): return profile + if "planning_horizon_goal" in value: + if not isinstance(value["planning_horizon_goal"], bool): + raise ValueError("planning_horizon_goal must be a boolean") + profile["planning_horizon_goal"] = value["planning_horizon_goal"] + if "stride_strategy" in value: from .control_plane.work_items.stride_policy_codec import normalize_stride_strategy profile["stride_strategy"] = normalize_stride_strategy(value["stride_strategy"]) @@ -249,6 +254,8 @@ def apply_goal_execution_profile_change( clear_replan_after_effective_turns: bool = False, stride_strategy: str | None = None, clear_stride_strategy: bool = False, + planning_horizon_goal: bool | None = None, + clear_planning_horizon_goal: bool = False, ) -> None: if ( clear_replan_after_completed_todos @@ -264,10 +271,14 @@ def apply_goal_execution_profile_change( raise ValueError("choose one review cadence override or clear operation") if stride_strategy is not None and clear_stride_strategy: raise ValueError("choose a stride strategy override or clear operation") + if planning_horizon_goal is not None and clear_planning_horizon_goal: + raise ValueError("choose a planning horizon Goal override or clear operation") raw = goal.get("execution_profile") profile = dict(raw) if isinstance(raw, dict) else {} if ( - stride_strategy is None + planning_horizon_goal is None + and not clear_planning_horizon_goal + and stride_strategy is None and not clear_stride_strategy and turn_granularity is None and replan_after_completed_todos is None @@ -276,6 +287,10 @@ def apply_goal_execution_profile_change( and not (clear_replan_after_completed_todos and "replan_after_completed_todos" in profile) ): return + if clear_planning_horizon_goal: + profile.pop("planning_horizon_goal", None) + if planning_horizon_goal is not None: + profile["planning_horizon_goal"] = planning_horizon_goal if clear_stride_strategy: profile.pop("stride_strategy", None) if stride_strategy is not None: diff --git a/tests/capabilities/test_machine_configuration_goal_defaults.py b/tests/capabilities/test_machine_configuration_goal_defaults.py index 408f882e80..d9e886f2b7 100644 --- a/tests/capabilities/test_machine_configuration_goal_defaults.py +++ b/tests/capabilities/test_machine_configuration_goal_defaults.py @@ -400,6 +400,7 @@ def test_goal_stride_only_uses_live_review_interval(tmp_path: Path) -> None: "stride_strategy": "evidence_conditioned_v1", }) assert options == { + "clear_execution_planning_horizon_goal": True, "execution_stride_strategy": "evidence_conditioned_v1", "clear_execution_replan_after_turns": True, "clear_execution_replan_after_todos": True, @@ -482,3 +483,41 @@ def test_stride_configuration_rejects_unsupported_or_ambiguous_versions(schema_v "schema_version": schema_version, "count_unit": "effective_turns", "count": 6, "stride_strategy": strategy, }) + + +def test_horizon_goal_configuration_opt_in_inheritance_and_clear(tmp_path): + from loopx.chat_goal_configuration_api import _goal_capability_options + from loopx.capabilities.goal_inspection import inspect_goal_capabilities + + _repo, registry_path, runtime_root = _fixture(tmp_path) + owner = build_builtin_machine_configuration_registry() + configuration = _machine_configuration() + configuration["namespaces"]["todo_replan_cadence"] = { + "schema_version": "todo_replan_cadence_machine_defaults_v2", + "count_unit": "effective_turns", "count": 4, "stride_strategy": "off", + "planning_horizon_goal": True, + } + assert "planning_horizon_goal" not in _history_goal(registry_path, runtime_root)["execution_profile"] + preview = configure_machine_configuration(runtime_root=runtime_root, registry=owner, configuration=configuration) + configure_machine_configuration(runtime_root=runtime_root, registry=owner, + configuration=configuration, execute=True, expected_plan_revision=preview["plan_revision"]) + assert _history_goal(registry_path, runtime_root)["execution_profile"]["planning_horizon_goal"] is True + for enabled in (False, True): + options = _goal_capability_options("todo_replan_cadence", {"planning_horizon_goal": enabled}) + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, **options) + profile = _history_goal(registry_path, runtime_root)["execution_profile"] + assert profile["planning_horizon_goal"] is enabled + assert profile["replan_after_effective_turns"] == 4 + inspected = inspect_goal_capabilities(registry_path=registry_path, runtime_root=runtime_root, + goal_id=GOAL_ID)["configuration"] + entry = next(x for x in inspected["capability_catalog"]["capabilities"] if x["capability_id"] == "todo_replan_cadence") + assert entry["current"] == {"planning_horizon_goal": enabled} + assert entry["effective_configuration"]["configuration"]["planning_horizon_goal"] is enabled + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, + clear_execution_planning_horizon_goal=True) + assert _history_goal(registry_path, runtime_root)["execution_profile"]["planning_horizon_goal"] is True + before = registry_path.read_bytes() + with pytest.raises(ValueError, match="planning_horizon_goal"): + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, + execution_planning_horizon_goal="true") + assert registry_path.read_bytes() == before diff --git a/tests/control_plane/test_quota_planning_horizon.py b/tests/control_plane/test_quota_planning_horizon.py index d4020c78e6..97afd36a14 100644 --- a/tests/control_plane/test_quota_planning_horizon.py +++ b/tests/control_plane/test_quota_planning_horizon.py @@ -1,6 +1,11 @@ from __future__ import annotations from collections.abc import Mapping +import json +from pathlib import Path +import shlex +import subprocess +import pytest from typing import Any from pytest import MonkeyPatch @@ -93,7 +98,7 @@ def test_default_quota_and_turn_envelope_expose_one_bounded_planning_horizon() - assert envelope["compaction"]["envelope_json_bytes"] <= 8_192 -def test_single_selected_todo_does_not_add_a_redundant_horizon() -> None: +def test_snapshot_without_goal_sources_does_not_add_a_redundant_horizon() -> None: selected = quota_todo_item( todo_id="todo_only_work", index=1, @@ -121,7 +126,7 @@ def test_single_selected_todo_does_not_add_a_redundant_horizon() -> None: assert "planning_horizon" not in packet -def test_flat_runnable_backlog_stays_in_action_portfolio_without_horizon() -> None: +def test_snapshot_flat_backlog_stays_in_action_portfolio_without_horizon() -> None: items = [ quota_todo_item( todo_id=f"todo_flat_{index:03d}", @@ -153,6 +158,93 @@ def test_flat_runnable_backlog_stays_in_action_portfolio_without_horizon() -> No assert "planning_horizon" not in packet +def test_preselection_horizon_and_replan_share_current_goal_sources(tmp_path: Path) -> None: + from loopx.control_plane.work_items.replan_context_codec import project_replan_context + + project = tmp_path / "project with spaces" + project.mkdir() + state = project / "ACTIVE_GOAL_STATE.md" + objective = "Deliver the complete useful outcome. " + "Retain original constraints. " * 20 + "Final priority: accuracy." + state.write_text("# Goal\n\n## Objective\n> " + objective + "\n\n## Agent Todo\n- Read the local report.\n") + runtime = tmp_path / "runtime" + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": GOAL_ID, "repo": str(project), "state_file": state.name, + "objective": "Registered fallback", "status": "active-read-only", "domain": "fixture", + }]})) + status = planning_horizon_strategic_context_status() + status.update(registry=str(registry), runtime_root=str(runtime)) + status["attention_queue"]["items"][0]["project_asset"]["execution_profile"] = {"planning_horizon_goal": True} + def read_packet(): + return build_quota_should_run(status, goal_id=GOAL_ID, agent_id=AGENT_ID, + turn_instance_id="turn-goal-horizon") + + original = read_packet() + horizon = original["planning_horizon"]["core_goal"] + assert horizon["objective"] == objective + assert horizon["objective_source"] == "active_state" + assert horizon["objective_missing"] is False + assert horizon == project_replan_context( + goal_id=GOAL_ID, agent_id=AGENT_ID, runs=(), source_status=status, + )["core_goal"] + readback = subprocess.run(shlex.split(horizon["read_action"]), capture_output=True, text=True, check=True) + assert readback.stdout == state.read_text() + assert original["planning_horizon"]["selection_contract"]["horizon_changes_selection"] is False + envelope = build_turn_envelope(original) + assert envelope["action"]["planning_horizon"]["core_goal"] == horizon + + state.write_text("# Goal\n\n## Objective\n> Current owner-corrected objective.\n") + corrected = read_packet() + assert corrected["planning_horizon"]["core_goal"]["objective"] == "Current owner-corrected objective." + assert corrected["selected_todo"] == original["selected_todo"] + assert corrected.get("action_portfolio") == original.get("action_portfolio") + assert corrected["should_run"] == original["should_run"] + assert corrected["interaction_contract"] == original["interaction_contract"] + assert quota_action_signature_document(corrected) != quota_action_signature_document(original) + state.write_text("# Goal\n") + fallback = read_packet() + assert fallback["planning_horizon"]["core_goal"]["objective"] == "Registered fallback" + assert fallback["planning_horizon"]["core_goal"]["objective_source"] == "registry" + registry_data = json.loads(registry.read_text()) + registry_data["goals"][0].pop("objective") + registry.write_text(json.dumps(registry_data)) + missing = read_packet() + assert missing["planning_horizon"]["core_goal"]["objective_missing"] is True + assert missing["planning_horizon"]["core_goal"]["objective"] is None + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_real_cli_exposes_goal_before_selection_without_granting_delivery(tmp_path, monkeypatch, provider): + from test_replan_successor_guard_reentry import _fixture, AGENT, GOAL + + call, runtime, index = _fixture(tmp_path, monkeypatch, provider, future_monitor=False) + index.write_text("") + call("configure-goal", "--goal-id", GOAL, "--execution-planning-horizon-goal", "--execute") + for number in range(2): + call("todo", "add", "--goal-id", GOAL, "--role", "agent", "--claimed-by", AGENT, + "--text", f"Validate independent source {number}.", "--task-class", "advancement_task", + "--action-kind", "validate") + args = ("quota", "should-run", "--codex-app", "--goal-id", GOAL, "--agent-id", AGENT) + before = call(*args, "--turn-instance-id", "turn-goal-selection") + core = before["planning_horizon"]["core_goal"] + assert core["objective"] == "Deliver the independently accepted source outcome." + assert core["objective_source"] == "active_state" + assert before["interaction_contract"]["agent_channel"]["delivery_allowed"] is False + assert before["interaction_contract"]["cli_channel"]["selection_required"] is True + assert "settlement_identity" not in before["heartbeat_receipt"] + state = tmp_path / "project/ACTIVE_GOAL_STATE.md" + assert subprocess.check_output(shlex.split(core["read_action"]), text=True) == state.read_text() + assert build_turn_envelope(before)["action"]["planning_horizon"]["core_goal"] == core + state.write_text(state.read_text().replace( + "Deliver the independently accepted source outcome.", "Deliver the corrected whole outcome.")) + corrected = call(*args, "--turn-instance-id", "turn-goal-corrected") + assert corrected["planning_horizon"]["core_goal"]["objective"] == "Deliver the corrected whole outcome." + assert corrected["action_portfolio"] == before["action_portfolio"] + assert corrected["selected_todo"] == before["selected_todo"] + assert corrected["interaction_contract"]["agent_channel"]["delivery_allowed"] is False + assert not index.read_text() + + def test_agent_todo_detail_expands_claim_semantics_from_the_shared_inventory() -> None: selected = quota_todo_item( todo_id="todo_selected_detail", @@ -312,3 +404,19 @@ def test_task_graph_reads_deferred_nodes_from_the_shared_planning_source() -> No for todo_id in node["refs"].get("todo_ids", []) } assert deferred["todo_id"] in todo_ids + + +@pytest.mark.parametrize("enabled", [False, None]) +def test_disabled_goal_projection_does_not_read_goal_sources(monkeypatch, enabled): + from loopx.control_plane.work_items import replan_context_codec + def forbidden(*args, **kwargs): + raise AssertionError("disabled horizon must not load Goal documents") + monkeypatch.setattr(replan_context_codec, "goal_context_facts", forbidden) + status = planning_horizon_strategic_context_status() + status["registry"] = "/unreadable/registry.json" + if enabled is not None: + status["attention_queue"]["items"][0]["project_asset"]["execution_profile"] = {"planning_horizon_goal": enabled} + packet = build_quota_should_run(status, goal_id=GOAL_ID, agent_id=AGENT_ID, + turn_instance_id="horizon-default-off") + assert "core_goal" not in packet["planning_horizon"] + assert packet["planning_horizon"]["selection_contract"]["horizon_changes_selection"] is False diff --git a/tests/control_plane/test_replan_context_evidence.py b/tests/control_plane/test_replan_context_evidence.py index 62dcfd4d7a..bcd03ff1b0 100644 --- a/tests/control_plane/test_replan_context_evidence.py +++ b/tests/control_plane/test_replan_context_evidence.py @@ -92,12 +92,12 @@ def test_goal_and_evidence_paths_survive_scoped_replan( def test_scoped_goal_keeps_original_text_without_public_filtering(tmp_path: Path) -> None: - from loopx.control_plane.work_items.replan_context_codec import _goal_facts + from loopx.control_plane.work_items.replan_context_codec import goal_context_facts registry = tmp_path / "registry.json" registry.write_text(json.dumps({"goals": [{"id": "evidence-goal", "objective": "Inspect access_key=synthetic-secret before continuing."}]})) - facts = _goal_facts({"registry": str(registry)}, "evidence-goal", None) + facts = goal_context_facts({"registry": str(registry)}, "evidence-goal", None) assert facts["registry_objective"] == "Inspect access_key=synthetic-secret before continuing." diff --git a/tests/control_plane_ts/planning_horizon.test.ts b/tests/control_plane_ts/planning_horizon.test.ts index d744288462..812daa2b93 100644 --- a/tests/control_plane_ts/planning_horizon.test.ts +++ b/tests/control_plane_ts/planning_horizon.test.ts @@ -58,9 +58,31 @@ function request(overrides: Record = {}) { source_context_todo_count: legacy.source_context_todo_count, }), acceptance_gaps: legacy.acceptance_gaps, + ...(overrides.goal_facts === undefined ? {} : {goal_facts: overrides.goal_facts}), }; } +test("one local task still carries the original Goal and separately scoped acceptance", () => { + const objective = "Deliver the whole accepted outcome. ".repeat(20) + "Preserve the final constraint."; + const result = projectQuotaPlanningHorizon(request({goal_facts: { + active_state_objective: objective, + registry_objective: "Older registered objective", + goal_read_action: "cat -- 'source with spaces/ACTIVE_GOAL_STATE.md'", + acceptance_contract: {objective: "Validate this slice only", scope: {kind: "selected_work"}}, + }})); + assert.deepEqual(result?.core_goal, { + objective, objective_source: "active_state", objective_missing: false, + read_action: "cat -- 'source with spaces/ACTIVE_GOAL_STATE.md'", + acceptance_contract: {objective: "Validate this slice only", scope: {kind: "selected_work"}}, + }); + assert.equal(result?.selected_todo_id, "todo_regression_gate"); + assert.equal((result?.selection_contract as Record).horizon_changes_selection, false); + assert.throws(() => projectQuotaPlanningHorizon(request({goal_facts: {active_state_objective: 42}}))); + assert.deepEqual(projectQuotaPlanningHorizon(request({goal_facts: {}}))?.core_goal, { + objective: null, objective_source: null, objective_missing: true, acceptance_contract: null, + }); +}); + test("planning horizon preserves the typed strategic chain around local selected work", () => { const facts = todo("todo_facts_source", 1, "P0", { status: "deferred", diff --git a/tests/test_chat_goal_configuration_api.py b/tests/test_chat_goal_configuration_api.py index 6172238005..5d3504a11a 100644 --- a/tests/test_chat_goal_configuration_api.py +++ b/tests/test_chat_goal_configuration_api.py @@ -324,6 +324,7 @@ def test_machine_inheritable_goal_capabilities_can_clear_their_overrides() -> No "clear_execution_replan_after_todos": True, "clear_execution_replan_after_turns": True, "clear_execution_stride_strategy": True, + "clear_execution_planning_horizon_goal": True, } assert _goal_capability_options("change_quality_qualification", None) == { "clear_change_quality_configuration": True