From b897f79e86aad469ea793df70ad8ae221d3eb220 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 01:59:09 +0800 Subject: [PATCH 1/3] feat: add live machine defaults for goal policy Signed-off-by: huangruiteng --- docs/quota-allocation.md | 25 ++- loopx/agent_onboarding.py | 22 +- loopx/capabilities/change_quality/README.md | 23 +- loopx/capabilities/change_quality/cli.py | 6 +- .../change_quality/goal_configuration.py | 74 +++++++ .../change_quality/machine_defaults.py | 112 ++++++++++ loopx/capabilities/change_quality/receipt.py | 35 ++- loopx/capabilities/configuration_ui.py | 11 +- .../machine_configuration/builtins.py | 66 +++++- .../todo_replan_cadence/__init__.py | 15 ++ .../todo_replan_cadence/goal_configuration.py | 20 ++ .../todo_replan_cadence/machine_defaults.py | 103 +++++++++ loopx/chat_goal_configuration_api.py | 6 +- loopx/cli_commands/registry_admin.py | 11 +- .../cli_commands/registry_admin_configure.py | 19 +- loopx/configuration_catalog.py | 24 +- loopx/configure_goal.py | 65 +++--- loopx/execution_profile.py | 49 ++++- loopx/history.py | 31 ++- .../test_capability_configuration_ui.py | 10 +- ...est_machine_configuration_goal_defaults.py | 208 ++++++++++++++++++ .../test_periodic_report_machine_store.py | 6 +- .../control_plane/test_todo_replan_cadence.py | 19 +- tests/test_chat_goal_configuration_api.py | 10 + tests/test_chat_machine_configuration_api.py | 61 ++--- 25 files changed, 917 insertions(+), 114 deletions(-) create mode 100644 loopx/capabilities/change_quality/goal_configuration.py create mode 100644 loopx/capabilities/change_quality/machine_defaults.py create mode 100644 loopx/capabilities/todo_replan_cadence/__init__.py create mode 100644 loopx/capabilities/todo_replan_cadence/goal_configuration.py create mode 100644 loopx/capabilities/todo_replan_cadence/machine_defaults.py create mode 100644 tests/capabilities/test_machine_configuration_goal_defaults.py diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 485eb8613d..1c96555ad8 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -54,11 +54,12 @@ depending on the executor: ## Completed-Todo Review Cadence -`execution_profile.replan_after_completed_todos` is a Goal-level integer -hyperparameter, default **5** in both standard and fine-grained Turn modes. -Set it to 2 or 3 for earlier review. Supported values are 1–5: the current -agent projection retains five recent completions, so larger values are rejected. -Restoring 5 removes the override and preserves the existing default behavior. +`execution_profile.replan_after_completed_todos` is an integer hyperparameter, +default **5** in both standard and fine-grained Turn modes. It may be set as a +live machine default, while an explicit Goal value remains pinned until it is +cleared. Set it to 2 or 3 for earlier review. Supported values are 1–5: the +current agent projection retains five recent completions, so larger values are +rejected. ```bash # Preview, apply, and read back the Goal setting. @@ -66,13 +67,17 @@ loopx configure-goal --goal-id example --execution-replan-after-todos 3 loopx configure-goal --goal-id example --execution-replan-after-todos 3 --execute loopx configure-goal --goal-id example -# Restore the default cadence. -loopx configure-goal --goal-id example --execution-replan-after-todos 5 --execute +# Restore live machine-default inheritance. +loopx configure-goal --goal-id example --clear-execution-replan-after-todos --execute ``` -The Dashboard's Goal configuration catalog exposes **Goal review cadence** as -an integer field through the existing preview/apply flow. This is a setting of -the built-in goal control plane, with no provider or plugin installation. +The Dashboard exposes **Goal review cadence** in both Machine Configuration and +Goal capability settings. Machine changes are revision-locked and affect every +Goal without an explicit override at its next quota/frontier evaluation. A Goal +override is a complete value, not a field merge; clearing it restores live +inheritance. Removing the machine namespace restores the capability default of +5. This is a setting of the built-in control plane, with no provider or plugin +installation. It does not create host turns, spend quota, or grant authority. The count includes completed advancement Todos claimed by the same Agent, with valid completion timestamps, after the latest qualifying outcome checkpoint. diff --git a/loopx/agent_onboarding.py b/loopx/agent_onboarding.py index ef10a8ddea..659914b742 100644 --- a/loopx/agent_onboarding.py +++ b/loopx/agent_onboarding.py @@ -19,9 +19,11 @@ KIRO_CLI_GOAL_CLEAR_COMMAND, KIRO_CLI_GOAL_COMPLETION_TOOL, KIRO_CLI_GOAL_DEFAULT_MAX_ITERATIONS, - SKILLS_ROOT_LABEL as KIRO_CLI_SKILLS_ROOT_LABEL, kiro_cli_goal_invocation, ) +from .kiro_cli_goal_mode import ( + SKILLS_ROOT_LABEL as KIRO_CLI_SKILLS_ROOT_LABEL, +) from .project_prompt import ( render_available_capability_args, render_codex_cli_install_preflight, @@ -432,6 +434,24 @@ def build_agent_onboarding_packet( ), {}, ) + from .capabilities.machine_configuration.builtins import ( + build_builtin_machine_configuration_registry, + project_goal_with_builtin_machine_configuration, + ) + from .capabilities.machine_configuration.store import read_machine_configuration + from .paths import resolve_runtime_root + + runtime_root = resolve_runtime_root( + registry, + registry_path=registry_path, + ) + goal = project_goal_with_builtin_machine_configuration( + goal, + read_machine_configuration( + runtime_root, + registry=build_builtin_machine_configuration_registry(), + ), + ) active_project_skill_ids = ( [CHANGE_QUALITY_SKILL_ID] if change_quality_goal_policy(goal)["enabled"] diff --git a/loopx/capabilities/change_quality/README.md b/loopx/capabilities/change_quality/README.md index e692528b26..8ff4d70f35 100644 --- a/loopx/capabilities/change_quality/README.md +++ b/loopx/capabilities/change_quality/README.md @@ -1,15 +1,17 @@ # Change Quality Qualification Change Quality Qualification gives a LoopX-managed goal a provider-neutral -final-diff review contract. It is default-off. A project opts in through goal -policy and chooses two independent controls: +final-diff review contract. It is default-off. A machine may define a live +default policy, and a Goal may pin a complete override. The selected policy +chooses two independent controls: | Policy | Meaning | | --- | --- | | `safe_fix` | Permit one bounded repair pass before the final review receipt | | `strict_receipt` | Require a passing receipt for the exact current diff at premerge | -`safe_fix` grants limited mutation authority; `strict_receipt` grants none. +`safe_fix` permits a bounded repair only inside authority the caller already +holds; it does not grant mutation authority. `strict_receipt` grants none. Projects may enable either, both, or neither after enabling the capability. ## Configure A Goal @@ -43,7 +45,20 @@ loopx configure-goal \ --execute ``` -Absence of this policy is equivalent to all three values being false. +To return a Goal to live machine-default inheritance: + +```bash +loopx configure-goal \ + --goal-id \ + --clear-change-quality-configuration \ + --execute +``` + +Machine defaults use the same Dashboard preview/apply/rollback flow as other +machine namespaces. An explicit Goal policy always wins as one complete value; +clearing it restores live inheritance. Removing the machine namespace restores +the capability default, where all three values are false. Neither scope grants +file, permission, or merge authority. ## Protocol diff --git a/loopx/capabilities/change_quality/cli.py b/loopx/capabilities/change_quality/cli.py index 6224c38d34..d0ba015978 100644 --- a/loopx/capabilities/change_quality/cli.py +++ b/loopx/capabilities/change_quality/cli.py @@ -12,7 +12,6 @@ verify_change_quality_receipt, ) - PrintPayload = Callable[ [dict[str, object], str, Callable[[dict[str, object]], str]], None, @@ -144,7 +143,10 @@ def handle_change_quality_command( } try: if args.change_quality_command == "prepare": - payload = build_change_quality_prepare_packet(**common) + payload = build_change_quality_prepare_packet( + **common, + runtime_root=runtime_root, + ) elif args.change_quality_command == "record": payload = record_change_quality_receipt( **common, diff --git a/loopx/capabilities/change_quality/goal_configuration.py b/loopx/capabilities/change_quality/goal_configuration.py new file mode 100644 index 0000000000..f262928c95 --- /dev/null +++ b/loopx/capabilities/change_quality/goal_configuration.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from .policy import ( + CHANGE_QUALITY_POLICY_SCHEMA_VERSION, + change_quality_goal_policy_summary, +) + +GoalChangeQualityChange = tuple[bool, bool | None, bool | None, bool | None] + + +def configuration_summary(goal: Mapping[str, Any]) -> dict[str, Any] | None: + control_plane = goal.get("control_plane") + if not isinstance(control_plane, Mapping) or not isinstance( + control_plane.get("change_quality_qualification"), Mapping + ): + return None + return dict(change_quality_goal_policy_summary(dict(goal))) + + +def normalize_change( + enabled: bool | None, + safe_fix: bool | None, + strict_receipt: bool | None, + *, + clear: bool, +) -> GoalChangeQualityChange: + if clear and any( + value is not None for value in (enabled, safe_fix, strict_receipt) + ): + raise ValueError( + "--clear-change-quality-configuration cannot be combined with " + "change-quality settings" + ) + return clear, enabled, safe_fix, strict_receipt + + +def apply_change(goal: dict[str, Any], change: GoalChangeQualityChange) -> None: + clear, enabled, safe_fix, strict_receipt = change + if not clear and all(value is None for value in (enabled, safe_fix, strict_receipt)): + return + raw_control_plane = goal.get("control_plane") + control_plane: dict[str, Any] = ( + dict(raw_control_plane) if isinstance(raw_control_plane, dict) else {} + ) + if clear: + control_plane.pop("change_quality_qualification", None) + if control_plane: + goal["control_plane"] = control_plane + else: + goal.pop("control_plane", None) + return + current = change_quality_goal_policy_summary(goal) + control_plane["change_quality_qualification"] = { + "schema_version": CHANGE_QUALITY_POLICY_SCHEMA_VERSION, + "enabled": enabled if enabled is not None else current["enabled"], + "safe_fix": safe_fix if safe_fix is not None else current["safe_fix"], + "strict_receipt": ( + strict_receipt + if strict_receipt is not None + else current["strict_receipt"] + ), + } + goal["control_plane"] = control_plane + + +__all__ = [ + "GoalChangeQualityChange", + "apply_change", + "configuration_summary", + "normalize_change", +] diff --git a/loopx/capabilities/change_quality/machine_defaults.py b/loopx/capabilities/change_quality/machine_defaults.py new file mode 100644 index 0000000000..69677b8854 --- /dev/null +++ b/loopx/capabilities/change_quality/machine_defaults.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy +from typing import Any + +from ..machine_configuration.contract import ( + MACHINE_CONFIGURATION_SCHEMA, + MachineConfigurationNamespace, +) +from .policy import CHANGE_QUALITY_POLICY_SCHEMA_VERSION + +CHANGE_QUALITY_MACHINE_DEFAULTS_SCHEMA = "change_quality_machine_defaults_v0" + + +def normalize_change_quality_machine_defaults( + raw: Mapping[str, Any], +) -> dict[str, Any]: + unknown = sorted( + set(raw) - {"schema_version", "enabled", "safe_fix", "strict_receipt"} + ) + if unknown: + raise ValueError( + "change_quality_qualification contains unsupported fields: " + + ", ".join(unknown) + ) + if raw.get("schema_version") != CHANGE_QUALITY_MACHINE_DEFAULTS_SCHEMA: + raise ValueError( + "change_quality_qualification must use " + + CHANGE_QUALITY_MACHINE_DEFAULTS_SCHEMA + ) + normalized: dict[str, Any] = { + "schema_version": CHANGE_QUALITY_MACHINE_DEFAULTS_SCHEMA, + } + for field in ("enabled", "safe_fix", "strict_receipt"): + value = raw.get(field) + if not isinstance(value, bool): + raise TypeError(f"change_quality_qualification.{field} must be a boolean") + normalized[field] = value + return normalized + + +def change_quality_machine_configuration_namespace() -> MachineConfigurationNamespace: + return MachineConfigurationNamespace( + namespace="change_quality_qualification", + schema_versions=frozenset({CHANGE_QUALITY_MACHINE_DEFAULTS_SCHEMA}), + normalize=normalize_change_quality_machine_defaults, + project_public=lambda value: dict(value), + apply_public_update=lambda _current, update: dict(update), + title="Change quality qualification", + description=( + "Live exact-diff qualification policy for Goals without an explicit " + "override. It does not grant file, permission, or merge authority." + ), + default_configuration={ + "schema_version": CHANGE_QUALITY_MACHINE_DEFAULTS_SCHEMA, + "enabled": False, + "safe_fix": False, + "strict_receipt": False, + }, + ) + + +def _machine_default( + machine_configuration: Mapping[str, Any] | None, +) -> dict[str, Any] | None: + if machine_configuration is None: + return None + if machine_configuration.get("schema_version") != MACHINE_CONFIGURATION_SCHEMA: + raise ValueError( + f"machine_configuration must use {MACHINE_CONFIGURATION_SCHEMA}" + ) + namespaces = machine_configuration.get("namespaces") + if not isinstance(namespaces, Mapping): + raise TypeError("machine_configuration.namespaces must be an object") + raw = namespaces.get("change_quality_qualification") + if raw is None: + return None + if not isinstance(raw, Mapping): + raise TypeError( + "machine_configuration.namespaces.change_quality_qualification must be an object" + ) + normalized = normalize_change_quality_machine_defaults(raw) + return { + "schema_version": CHANGE_QUALITY_POLICY_SCHEMA_VERSION, + "enabled": normalized["enabled"], + "safe_fix": normalized["safe_fix"], + "strict_receipt": normalized["strict_receipt"], + } + + +def apply_change_quality_machine_default( + goal: Mapping[str, Any], + machine_configuration: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Project the live machine policy without overwriting a Goal override.""" + + projected = deepcopy(dict(goal)) + raw_control_plane = goal.get("control_plane") + if isinstance(raw_control_plane, Mapping) and isinstance( + raw_control_plane.get("change_quality_qualification"), Mapping + ): + return projected + machine_default = _machine_default(machine_configuration) + if machine_default is None: + return projected + control_plane = ( + dict(raw_control_plane) if isinstance(raw_control_plane, Mapping) else {} + ) + control_plane["change_quality_qualification"] = machine_default + projected["control_plane"] = control_plane + return projected diff --git a/loopx/capabilities/change_quality/receipt.py b/loopx/capabilities/change_quality/receipt.py index 2aae838d8f..d935f23ffe 100644 --- a/loopx/capabilities/change_quality/receipt.py +++ b/loopx/capabilities/change_quality/receipt.py @@ -22,7 +22,6 @@ ) from .scope import build_change_quality_scope, resolve_git_root - CHANGE_QUALITY_PREPARE_SCHEMA_VERSION = "change_quality_prepare_packet_v2" CHANGE_QUALITY_RECEIPT_SCHEMA_VERSION = "change_quality_receipt_v2" CHANGE_QUALITY_VERIFY_SCHEMA_VERSION = "change_quality_receipt_verification_v2" @@ -74,7 +73,12 @@ } -def _goal_from_registry(registry_path: Path, goal_id: str) -> dict[str, Any]: +def _goal_from_registry( + registry_path: Path, + goal_id: str, + *, + runtime_root: Path | None = None, +) -> dict[str, Any]: registry = read_json(registry_path) goal = next( ( @@ -86,6 +90,20 @@ def _goal_from_registry(registry_path: Path, goal_id: str) -> dict[str, Any]: ) if goal is None: raise ValueError(f"goal_id not found in registry: {goal_id}") + if runtime_root is not None: + from ..machine_configuration.builtins import ( + build_builtin_machine_configuration_registry, + project_goal_with_builtin_machine_configuration, + ) + from ..machine_configuration.store import read_machine_configuration + + machine_configuration = read_machine_configuration( + runtime_root, + registry=build_builtin_machine_configuration_registry(), + ) + return project_goal_with_builtin_machine_configuration( + goal, machine_configuration + ) return goal @@ -142,8 +160,11 @@ def build_change_quality_prepare_packet( goal_id: str, repo_path: Path, base_ref: str = "origin/main", + runtime_root: Path | None = None, ) -> dict[str, Any]: - goal = _goal_from_registry(registry_path, goal_id) + goal = _goal_from_registry( + registry_path, goal_id, runtime_root=runtime_root + ) policy = change_quality_goal_policy(goal) scope = build_change_quality_scope(repo_path=repo_path, base_ref=base_ref) repository_context = build_change_quality_repository_context( @@ -238,7 +259,9 @@ def record_change_quality_receipt( base_ref: str = "origin/main", execute: bool = False, ) -> dict[str, Any]: - goal = _goal_from_registry(registry_path, goal_id) + goal = _goal_from_registry( + registry_path, goal_id, runtime_root=runtime_root + ) policy = change_quality_goal_policy(goal) if not policy["enabled"]: raise ValueError("change-quality qualification is disabled for this goal") @@ -390,7 +413,9 @@ def verify_change_quality_receipt( repo_path: Path, base_ref: str = "origin/main", ) -> dict[str, Any]: - goal = _goal_from_registry(registry_path, goal_id) + goal = _goal_from_registry( + registry_path, goal_id, runtime_root=runtime_root + ) policy = change_quality_goal_policy(goal) scope = build_change_quality_scope(repo_path=repo_path, base_ref=base_ref) if not policy["enabled"]: diff --git a/loopx/capabilities/configuration_ui.py b/loopx/capabilities/configuration_ui.py index 6f2d51ad75..1e471cf3ae 100644 --- a/loopx/capabilities/configuration_ui.py +++ b/loopx/capabilities/configuration_ui.py @@ -59,8 +59,8 @@ def capability_configuration_editor( definitions: dict[str, dict[str, Any]] = { "todo_replan_cadence": { - "supported_scopes": ["goal"], - "writable_scopes": ["goal"], + "supported_scopes": ["machine", "goal"], + "writable_scopes": ["machine", "goal"], "fields": [ _field( "completed_todos", @@ -71,7 +71,8 @@ def capability_configuration_editor( required=True, description=( "Default 5 in both turn modes. Use 2 or 3 for earlier review; " - "5 restores the default. Counts this Agent's advancement work." + "the Goal editor writes an explicit override. Counts this " + "Agent's advancement work." ), ), ], @@ -165,8 +166,8 @@ def capability_configuration_editor( ], }, "change_quality_qualification": { - "supported_scopes": ["goal"], - "writable_scopes": ["goal"], + "supported_scopes": ["machine", "goal"], + "writable_scopes": ["machine", "goal"], "fields": [ _field("enabled", "Enabled", "boolean"), _field("safe_fix", "Allow one bounded safe-fix pass", "boolean"), diff --git a/loopx/capabilities/machine_configuration/builtins.py b/loopx/capabilities/machine_configuration/builtins.py index f27a0cdeab..90b294534d 100644 --- a/loopx/capabilities/machine_configuration/builtins.py +++ b/loopx/capabilities/machine_configuration/builtins.py @@ -1,18 +1,78 @@ from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from .contract import MachineConfigurationRegistry def build_builtin_machine_configuration_registry() -> MachineConfigurationRegistry: # Imports stay at the composition boundary: the generic contract does not # depend on any consumer capability. + from ..change_quality.machine_defaults import ( + change_quality_machine_configuration_namespace, + ) from ..periodic_report.machine_defaults import ( periodic_report_machine_configuration_namespace, ) + from ..todo_replan_cadence.machine_defaults import ( + todo_replan_cadence_machine_configuration_namespace, + ) - return MachineConfigurationRegistry().register( - periodic_report_machine_configuration_namespace() + return ( + MachineConfigurationRegistry() + .register(periodic_report_machine_configuration_namespace()) + .register(todo_replan_cadence_machine_configuration_namespace()) + .register(change_quality_machine_configuration_namespace()) ) -__all__ = ["build_builtin_machine_configuration_registry"] +def project_goal_with_builtin_machine_configuration( + goal: Mapping[str, Any], + machine_configuration: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Compose capability-owned live defaults into one read-only Goal projection.""" + + from ..change_quality.machine_defaults import ( + apply_change_quality_machine_default, + ) + from ..todo_replan_cadence.machine_defaults import ( + apply_todo_replan_cadence_machine_default, + ) + + projected = apply_todo_replan_cadence_machine_default( + goal, machine_configuration + ) + return dict( + apply_change_quality_machine_default(projected, machine_configuration) + ) + + +def builtin_machine_inheritable_goal_overrides( + goal: Mapping[str, Any], +) -> dict[str, dict[str, Any]]: + """Project explicit Goal values for capabilities with live machine defaults.""" + + from ..change_quality.goal_configuration import ( + configuration_summary as change_quality_summary, + ) + from ..todo_replan_cadence.goal_configuration import ( + configuration_summary as cadence_summary, + ) + + summaries = { + "change_quality_qualification": change_quality_summary(goal), + "todo_replan_cadence": cadence_summary(goal), + } + return { + capability_id: summary + for capability_id, summary in summaries.items() + if summary is not None + } + + +__all__ = [ + "build_builtin_machine_configuration_registry", + "builtin_machine_inheritable_goal_overrides", + "project_goal_with_builtin_machine_configuration", +] diff --git a/loopx/capabilities/todo_replan_cadence/__init__.py b/loopx/capabilities/todo_replan_cadence/__init__.py new file mode 100644 index 0000000000..4ffd6089f6 --- /dev/null +++ b/loopx/capabilities/todo_replan_cadence/__init__.py @@ -0,0 +1,15 @@ +"""Goal review cadence capability.""" + +from .machine_defaults import ( + TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA, + apply_todo_replan_cadence_machine_default, + normalize_todo_replan_cadence_machine_defaults, + todo_replan_cadence_machine_configuration_namespace, +) + +__all__ = [ + "TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA", + "apply_todo_replan_cadence_machine_default", + "normalize_todo_replan_cadence_machine_defaults", + "todo_replan_cadence_machine_configuration_namespace", +] diff --git a/loopx/capabilities/todo_replan_cadence/goal_configuration.py b/loopx/capabilities/todo_replan_cadence/goal_configuration.py new file mode 100644 index 0000000000..3993b0f799 --- /dev/null +++ b/loopx/capabilities/todo_replan_cadence/goal_configuration.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ...control_plane.goals.goal_vision_policy import ( + completed_todo_replan_threshold, +) + + +def configuration_summary(goal: Mapping[str, Any]) -> dict[str, Any] | None: + profile = goal.get("execution_profile") + if not isinstance(profile, Mapping) or ( + "replan_after_completed_todos" not in profile + ): + return None + return {"completed_todos": completed_todo_replan_threshold(dict(profile))} + + +__all__ = ["configuration_summary"] diff --git a/loopx/capabilities/todo_replan_cadence/machine_defaults.py b/loopx/capabilities/todo_replan_cadence/machine_defaults.py new file mode 100644 index 0000000000..e823e656ec --- /dev/null +++ b/loopx/capabilities/todo_replan_cadence/machine_defaults.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections.abc import Mapping +from copy import deepcopy +from typing import Any + +from ...control_plane.goals.goal_vision_policy import ( + COMPLETED_TODO_CHAIN_REPLAN_THRESHOLD, + normalize_completed_todo_replan_threshold, +) +from ..machine_configuration.contract import ( + MACHINE_CONFIGURATION_SCHEMA, + MachineConfigurationNamespace, +) + +TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA = ( + "todo_replan_cadence_machine_defaults_v0" +) + + +def normalize_todo_replan_cadence_machine_defaults( + raw: Mapping[str, Any], +) -> dict[str, Any]: + unknown = sorted(set(raw) - {"schema_version", "completed_todos"}) + if unknown: + raise ValueError( + "todo_replan_cadence contains unsupported fields: " + ", ".join(unknown) + ) + if raw.get("schema_version") != TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA: + raise ValueError( + "todo_replan_cadence must use " + + TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA + ) + return { + "schema_version": TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA, + "completed_todos": normalize_completed_todo_replan_threshold( + raw.get("completed_todos") + ), + } + + +def todo_replan_cadence_machine_configuration_namespace( +) -> MachineConfigurationNamespace: + return MachineConfigurationNamespace( + namespace="todo_replan_cadence", + schema_versions=frozenset({TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA}), + normalize=normalize_todo_replan_cadence_machine_defaults, + project_public=lambda value: dict(value), + apply_public_update=lambda _current, update: dict(update), + title="Goal review cadence", + description=( + "Live review threshold for Goals without an explicit cadence override. " + "It changes when LoopX reviews a completed Todo chain; it does not create " + "turns, spend quota, or grant authority." + ), + default_configuration={ + "schema_version": TODO_REPLAN_CADENCE_MACHINE_DEFAULTS_SCHEMA, + "completed_todos": COMPLETED_TODO_CHAIN_REPLAN_THRESHOLD, + }, + ) + + +def _machine_default( + machine_configuration: Mapping[str, Any] | None, +) -> int | None: + if machine_configuration is None: + return None + if machine_configuration.get("schema_version") != MACHINE_CONFIGURATION_SCHEMA: + raise ValueError( + f"machine_configuration must use {MACHINE_CONFIGURATION_SCHEMA}" + ) + namespaces = machine_configuration.get("namespaces") + if not isinstance(namespaces, Mapping): + raise TypeError("machine_configuration.namespaces must be an object") + raw = namespaces.get("todo_replan_cadence") + if raw is None: + return None + if not isinstance(raw, Mapping): + raise TypeError( + "machine_configuration.namespaces.todo_replan_cadence must be an object" + ) + return int( + normalize_todo_replan_cadence_machine_defaults(raw)["completed_todos"] + ) + + +def apply_todo_replan_cadence_machine_default( + goal: Mapping[str, Any], + machine_configuration: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Project the live machine default without overwriting a Goal override.""" + + projected = deepcopy(dict(goal)) + raw_profile = goal.get("execution_profile") + if isinstance(raw_profile, Mapping) and "replan_after_completed_todos" in raw_profile: + return projected + completed_todos = _machine_default(machine_configuration) + if completed_todos is None: + return projected + profile = dict(raw_profile) if isinstance(raw_profile, Mapping) else {} + profile["replan_after_completed_todos"] = completed_todos + projected["execution_profile"] = profile + return projected diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index 8d3c0ba03b..a20c1e5354 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -19,11 +19,11 @@ require_expected_configuration_plan_revision, ) from .configure_goal import configure_goal -from .orchestration import subagent_model_configuration_options from .control_plane.goals.configure_goal_service import configure_goal_with_global_sync from .control_plane.goals.goal_vision_policy import ( normalize_completed_todo_replan_threshold, ) +from .orchestration import subagent_model_configuration_options CHAT_GOAL_CONFIGURATION_PATH = "/api/chat/goal-configuration" CHAT_GOAL_CONFIGURATION_PREVIEW_PATH = f"{CHAT_GOAL_CONFIGURATION_PATH}/preview" @@ -131,6 +131,10 @@ def _goal_capability_options( if configuration is None: if capability_id == "periodic_report": return {"clear_periodic_report_configuration": True} + if capability_id == "todo_replan_cadence": + return {"clear_execution_replan_after_todos": True} + if capability_id == "change_quality_qualification": + return {"clear_change_quality_configuration": True} raise ValueError(f"Goal capability cannot be cleared: {capability_id}") config = dict(configuration) allowed: dict[str, set[str]] = { diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index bff959530e..41d6ebe986 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -21,7 +21,10 @@ unbind_thread_agent_in_registry, ) from ..upgrade import build_upgrade_plan -from .goal_lifecycle import handle_goal_lifecycle_command, register_goal_lifecycle_command +from .goal_lifecycle import ( + handle_goal_lifecycle_command, + register_goal_lifecycle_command, +) from .registry_admin_configure import register_configure_goal_command from .registry_admin_lifecycle import ( REGISTRY_LIFECYCLE_COMMANDS, @@ -456,12 +459,18 @@ def handle_registry_admin_command( quota_window_hours=args.quota_window_hours, execution_turn_granularity=args.execution_turn_granularity, execution_replan_after_todos=args.execution_replan_after_todos, + clear_execution_replan_after_todos=bool( + args.clear_execution_replan_after_todos + ), self_repair_enabled=args.self_repair_enabled, self_repair_health=args.self_repair_health, self_repair_waiting_projection=args.self_repair_waiting_projection, change_quality_enabled=args.change_quality_enabled, change_quality_safe_fix=args.change_quality_safe_fix, change_quality_strict_receipt=args.change_quality_strict_receipt, + clear_change_quality_configuration=bool( + args.clear_change_quality_configuration + ), multi_subagent_feature=args.multi_subagent_feature, orchestration_mode=args.orchestration_mode, spawn_allowed=args.spawn_allowed, diff --git a/loopx/cli_commands/registry_admin_configure.py b/loopx/cli_commands/registry_admin_configure.py index 37e78261c2..3bbaf51875 100644 --- a/loopx/cli_commands/registry_admin_configure.py +++ b/loopx/cli_commands/registry_admin_configure.py @@ -41,7 +41,16 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N help=( "Require goal review after this many same-agent advancement Todo completions " "without a covering outcome checkpoint. Default 5; use 2 or 3 for earlier " - "review, or 5 to restore the default. Applies to standard and fine modes." + "review. This writes a Goal override; use the clear flag to inherit the " + "machine default. Applies to standard and fine modes." + ), + ) + configure_goal_parser.add_argument( + "--clear-execution-replan-after-todos", + action="store_true", + help=( + "Remove the Goal review-cadence override and restore live machine-default " + "inheritance." ), ) configure_goal_parser.add_argument( @@ -88,6 +97,14 @@ def register_configure_goal_command(subparsers: argparse._SubParsersAction) -> N default=None, help="Require a valid exact-scope quality receipt at premerge.", ) + configure_goal_parser.add_argument( + "--clear-change-quality-configuration", + action="store_true", + help=( + "Remove the complete Goal change-quality override and restore live " + "machine-default inheritance." + ), + ) configure_goal_parser.add_argument( "--multi-subagent-feature", choices=["off", "enabled"], diff --git a/loopx/configuration_catalog.py b/loopx/configuration_catalog.py index e7c39c1ae7..42f0f2add1 100644 --- a/loopx/configuration_catalog.py +++ b/loopx/configuration_catalog.py @@ -4,8 +4,8 @@ from collections.abc import Mapping, Sequence from typing import Any -from .control_plane.agent_context import agent_context_descriptor from .capabilities.configuration_ui import build_capability_configuration_catalog +from .control_plane.agent_context import agent_context_descriptor from .control_plane.goals.goal_vision_policy import completed_todo_replan_threshold DEFAULT_MULTI_SUBAGENT_MAX_CHILDREN = 2 @@ -49,6 +49,9 @@ def build_goal_configuration_catalog( feature_summary: Mapping[str, Any], default_multi_subagent_max_children: int, explore_harness_profiles: Sequence[str], + machine_inheritable_goal_overrides: Mapping[ + str, Mapping[str, Any] + ] | None = None, ) -> dict[str, Any]: """Build the on-demand configuration read model for optional features.""" @@ -111,7 +114,7 @@ def build_goal_configuration_catalog( "generic", ) - catalog = { + catalog: dict[str, Any] = { "schema_version": "loopx_goal_configuration_catalog_v0", "scope": "default_off_optional_capabilities", "all_settings_help_command": "loopx configure-goal --help", @@ -155,10 +158,10 @@ def build_goal_configuration_catalog( goal_id, "--execution-replan-after-todos", "3", execute=True ), "preview_disable": _configure_command( - goal_id, "--execution-replan-after-todos", "5" + goal_id, "--clear-execution-replan-after-todos" ), "apply_disable": _configure_command( - goal_id, "--execution-replan-after-todos", "5", execute=True + goal_id, "--clear-execution-replan-after-todos", execute=True ), "verify": [inspect_command], }, @@ -768,6 +771,19 @@ def build_goal_configuration_catalog( "documentation": {}, } ) + overrides = machine_inheritable_goal_overrides or {} + for feature in catalog["features"]: + feature_id = str(feature.get("feature_id") or "") + if feature_id not in { + "todo_replan_cadence", + "change_quality_qualification", + }: + continue + explicit = overrides.get(feature_id) + if isinstance(explicit, Mapping): + feature["current"] = dict(explicit) + else: + feature.pop("current", None) catalog["capability_catalog"] = build_capability_configuration_catalog( goal_features=catalog["features"], explore_harness_profiles=explore_harness_profiles, diff --git a/loopx/configure_goal.py b/loopx/configure_goal.py index 0d13e5f89f..e3b53ded17 100644 --- a/loopx/configure_goal.py +++ b/loopx/configure_goal.py @@ -15,9 +15,10 @@ build_checkpointed_boundary_authority_entry, checkpointed_boundary_authority_summary, ) -from .capabilities.change_quality.policy import ( - CHANGE_QUALITY_POLICY_SCHEMA_VERSION, - change_quality_goal_policy_summary, +from .capabilities.change_quality import goal_configuration as change_quality_config +from .capabilities.change_quality.policy import change_quality_goal_policy_summary +from .capabilities.machine_configuration.builtins import ( + builtin_machine_inheritable_goal_overrides, ) from .capabilities.periodic_report import goal_configuration as periodic_report_config from .configuration_catalog import ( @@ -51,8 +52,8 @@ normalize_todo_lifecycle_authority, ) from .execution_profile import ( + apply_goal_execution_profile_change, compact_execution_profile, - configure_execution_profile, ) from .explore_graph import compact_explore_graph_policy from .orchestration import ( @@ -424,6 +425,7 @@ def configure_goal( quota_window_hours: float | None = None, execution_turn_granularity: str | None = None, execution_replan_after_todos: int | None = None, + clear_execution_replan_after_todos: bool = False, self_repair_enabled: bool | None = None, self_repair_health: bool | None = None, self_repair_waiting_projection: bool | None = None, @@ -431,6 +433,7 @@ def configure_goal( change_quality_enabled: bool | None = None, change_quality_safe_fix: bool | None = None, change_quality_strict_receipt: bool | None = None, + clear_change_quality_configuration: bool = False, multi_subagent_feature: str | None = None, orchestration_mode: str | None = None, spawn_allowed: bool | None = None, @@ -663,6 +666,12 @@ def configure_goal( periodic_report_change = periodic_report_config.normalize_change( periodic_report_configuration, clear=clear_periodic_report_configuration ) + change_quality_change = change_quality_config.normalize_change( + change_quality_enabled, + change_quality_safe_fix, + change_quality_strict_receipt, + clear=clear_change_quality_configuration, + ) payload = read_json(registry_path) goals = registry_goals(payload) goal = next((item for item in goals if str(item.get("id")) == goal_id), None) @@ -773,12 +782,12 @@ def configure_goal( before_goal = deepcopy(goal) before = _settings_summary(before_goal) - if execution_turn_granularity is not None or execution_replan_after_todos is not None: - goal["execution_profile"] = configure_execution_profile( - goal.get("execution_profile"), - turn_granularity=execution_turn_granularity, - replan_after_completed_todos=execution_replan_after_todos, - ) + apply_goal_execution_profile_change( + goal, + turn_granularity=execution_turn_granularity, + replan_after_completed_todos=execution_replan_after_todos, + clear_replan_after_completed_todos=clear_execution_replan_after_todos, + ) legacy_hierarchy_before = legacy_agent_hierarchy_present(before_goal) expected_migration_id = peer_agent_runtime_migration_id(goal_id, before_goal) completed_migration_before = completed_peer_agent_runtime_migration(before_goal) @@ -849,32 +858,7 @@ def configure_goal( ) control_plane["self_repair"] = self_repair periodic_report_config.apply_change(goal, periodic_report_change) - if ( - change_quality_enabled is not None - or change_quality_safe_fix is not None - or change_quality_strict_receipt is not None - ): - control_plane = _mutable_control_plane(goal) - current = change_quality_goal_policy_summary(goal) - change_quality = { - "schema_version": CHANGE_QUALITY_POLICY_SCHEMA_VERSION, - "enabled": ( - change_quality_enabled - if change_quality_enabled is not None - else current["enabled"] - ), - "safe_fix": ( - change_quality_safe_fix - if change_quality_safe_fix is not None - else current["safe_fix"] - ), - "strict_receipt": ( - change_quality_strict_receipt - if change_quality_strict_receipt is not None - else current["strict_receipt"] - ), - } - control_plane["change_quality_qualification"] = change_quality + change_quality_config.apply_change(goal, change_quality_change) if ( issue_fix_reviewer_notification_config is not None or clear_issue_fix_reviewer_notification_config @@ -1248,7 +1232,11 @@ def configure_goal( # Some local-private control-plane bindings intentionally project only # counts and booleans. Rebinding one enabled provider to another can # therefore preserve the public summary while still requiring a write. - changed_fields = ["control_plane"] + changed_fields = [ + field + for field in ("execution_profile", "control_plane") + if before_goal.get(field) != goal.get(field) + ] or ["control_plane"] dry_run = not execute model_changed = bool( before.get("legacy_hierarchy_present") @@ -1327,6 +1315,9 @@ def configure_goal( feature_summary=feature_summary, default_multi_subagent_max_children=DEFAULT_MULTI_SUBAGENT_MAX_CHILDREN, explore_harness_profiles=EXPLORE_HARNESS_PROFILES, + machine_inheritable_goal_overrides=( + builtin_machine_inheritable_goal_overrides(goal) + ), ), "heartbeat_prompt_migration": _build_heartbeat_prompt_migration( goal_id=goal_id, diff --git a/loopx/execution_profile.py b/loopx/execution_profile.py index 2c49a2a351..10fd38e10e 100644 --- a/loopx/execution_profile.py +++ b/loopx/execution_profile.py @@ -207,11 +207,52 @@ def configure_execution_profile( normalized = compact_execution_profile(profile) if turn_granularity is not None: normalized = execution_profile_with_turn_granularity(normalized, turn_granularity) - if replan_after_completed_todos is not None: - normalized["replan_after_completed_todos"] = normalize_completed_todo_replan_threshold( - replan_after_completed_todos + configured_threshold = ( + normalize_completed_todo_replan_threshold(replan_after_completed_todos) + if replan_after_completed_todos is not None + else None + ) + if configured_threshold is not None: + normalized["replan_after_completed_todos"] = configured_threshold + configured = compact_execution_profile(normalized) + if configured_threshold is not None: + # The default value still represents an explicit Goal override. Keep the + # field so a machine default can change without silently changing this Goal. + configured["replan_after_completed_todos"] = configured_threshold + return configured + + +def apply_goal_execution_profile_change( + goal: dict[str, Any], + *, + turn_granularity: str | None, + replan_after_completed_todos: int | None, + clear_replan_after_completed_todos: bool, +) -> None: + if ( + clear_replan_after_completed_todos + and replan_after_completed_todos is not None + ): + raise ValueError( + "--clear-execution-replan-after-todos cannot be combined with " + "--execution-replan-after-todos" ) - return compact_execution_profile(normalized) + raw = goal.get("execution_profile") + profile = dict(raw) if isinstance(raw, dict) else {} + override_present = "replan_after_completed_todos" in profile + if clear_replan_after_completed_todos: + profile.pop("replan_after_completed_todos", None) + if ( + turn_granularity is None + and replan_after_completed_todos is None + and not override_present + ): + return + goal["execution_profile"] = configure_execution_profile( + profile, + turn_granularity=turn_granularity, + replan_after_completed_todos=replan_after_completed_todos, + ) def normalize_turn_granularity(value: Any) -> str: diff --git a/loopx/history.py b/loopx/history.py index 26211faa12..7d247d9ea1 100644 --- a/loopx/history.py +++ b/loopx/history.py @@ -10,6 +10,11 @@ from .authority import goal_authority_registry_summary from .control_plane import compact_control_plane_policy +from .control_plane.goals.activation import ( + GoalActivationState, + goal_activation_state, + normalize_goal_activation_state, +) from .control_plane.quota.monitor_poll import QUOTA_MONITOR_POLL_CLASSIFICATION from .control_plane.quota.slot_accounting import ( QUOTA_SLOT_SPENT_CLASSIFICATION, @@ -35,11 +40,6 @@ collision_review_groups, validate_reviewed_collision_plan, ) -from .control_plane.goals.activation import ( - GoalActivationState, - goal_activation_state, - normalize_goal_activation_state, -) from .control_plane.runtime.time import now_local_iso, parse_timestamp from .doctor import PROMOTION_READINESS_CLASSIFICATIONS from .execution_profile import compact_execution_profile @@ -110,7 +110,9 @@ def write_reserved_run_artifacts( render_markdown: Callable[[dict[str, Any]], str], ) -> None: from .control_plane.quota.usage_collector import ingest_usage_into_run_record - from .control_plane.work_items.delivery_history import require_consistent_delivery_claim + from .control_plane.work_items.delivery_history import ( + require_consistent_delivery_claim, + ) require_consistent_delivery_claim(record) require_consistent_delivery_claim(index_record) @@ -251,8 +253,23 @@ def collect_history( include_runtime_goals: bool = True, activation_state_filter: GoalActivationState | str | None = None, ) -> dict[str, Any]: + from .capabilities.machine_configuration.builtins import ( + build_builtin_machine_configuration_registry, + project_goal_with_builtin_machine_configuration, + ) + from .capabilities.machine_configuration.store import read_machine_configuration + registry = load_registry(registry_path) - goal_meta = {str(goal.get("id")): goal for goal in registry_goals(registry)} + machine_configuration = read_machine_configuration( + runtime_root, + registry=build_builtin_machine_configuration_registry(), + ) + goal_meta = { + str(goal.get("id")): project_goal_with_builtin_machine_configuration( + goal, machine_configuration + ) + for goal in registry_goals(registry) + } activation_filter = ( normalize_goal_activation_state(activation_state_filter) if activation_state_filter is not None diff --git a/tests/capabilities/test_capability_configuration_ui.py b/tests/capabilities/test_capability_configuration_ui.py index 97c8d5e51b..2371b8e344 100644 --- a/tests/capabilities/test_capability_configuration_ui.py +++ b/tests/capabilities/test_capability_configuration_ui.py @@ -108,7 +108,15 @@ def test_goal_configuration_uses_the_shared_capability_catalog() -> None: assert {item["capability_id"] for item in shared["capabilities"]} == { item["feature_id"] for item in catalog["features"] } - assert all(item["available_scopes"] == ["goal"] for item in shared["capabilities"]) + scopes = { + item["capability_id"]: item["available_scopes"] + for item in shared["capabilities"] + } + assert scopes["todo_replan_cadence"] == ["goal"] + assert scopes["change_quality_qualification"] == ["goal"] + assert all( + item["available_scopes"] == ["goal"] for item in shared["capabilities"] + ) multi_subagent = next( item for item in shared["capabilities"] diff --git a/tests/capabilities/test_machine_configuration_goal_defaults.py b/tests/capabilities/test_machine_configuration_goal_defaults.py new file mode 100644 index 0000000000..8f4ab74126 --- /dev/null +++ b/tests/capabilities/test_machine_configuration_goal_defaults.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from loopx.capabilities.change_quality.machine_defaults import ( + normalize_change_quality_machine_defaults, +) +from loopx.capabilities.change_quality.receipt import ( + build_change_quality_prepare_packet, +) +from loopx.capabilities.machine_configuration.builtins import ( + build_builtin_machine_configuration_registry, +) +from loopx.capabilities.machine_configuration.store import ( + configure_machine_configuration, +) +from loopx.capabilities.todo_replan_cadence.machine_defaults import ( + normalize_todo_replan_cadence_machine_defaults, +) +from loopx.configure_goal import configure_goal +from loopx.control_plane.goals.goal_vision_policy import ( + completed_todo_replan_threshold, +) +from loopx.history import collect_history + +GOAL_ID = "machine-default-fixture" + + +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ) + + +def _machine_configuration() -> dict[str, object]: + return { + "schema_version": "loopx_machine_configuration_v0", + "namespaces": { + "todo_replan_cadence": { + "schema_version": "todo_replan_cadence_machine_defaults_v0", + "completed_todos": 2, + }, + "change_quality_qualification": { + "schema_version": "change_quality_machine_defaults_v0", + "enabled": True, + "safe_fix": False, + "strict_receipt": False, + }, + }, + } + + +def _apply_machine_configuration(runtime_root: Path) -> None: + registry = build_builtin_machine_configuration_registry() + configuration = _machine_configuration() + preview = configure_machine_configuration( + runtime_root=runtime_root, + configuration=configuration, + registry=registry, + ) + configure_machine_configuration( + runtime_root=runtime_root, + configuration=configuration, + registry=registry, + execute=True, + expected_plan_revision=preview["plan_revision"], + ) + + +def _fixture(tmp_path: Path) -> tuple[Path, Path, Path]: + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "fixture@example.invalid") + _git(repo, "config", "user.name", "Machine Defaults Fixture") + (repo / "app.py").write_text("value = 1\n", encoding="utf-8") + _git(repo, "add", "app.py") + _git(repo, "commit", "-m", "fixture") + runtime_root = tmp_path / "runtime" + registry_path = tmp_path / "registry.json" + registry_path.write_text( + json.dumps( + { + "common_runtime_root": str(runtime_root), + "goals": [{"id": GOAL_ID, "repo": str(repo), "status": "active"}], + } + ), + encoding="utf-8", + ) + _apply_machine_configuration(runtime_root) + return repo, registry_path, runtime_root + + +def _history_goal(registry_path: Path, runtime_root: Path) -> dict[str, object]: + history = collect_history( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=GOAL_ID, + limit=5, + ) + return history["goals"][0] + + +@pytest.mark.parametrize("completed_todos", [True, 0, 6, "3"]) +def test_cadence_machine_default_rejects_values_outside_the_typed_contract( + completed_todos: object, +) -> None: + with pytest.raises(ValueError, match="integer from 1 to 5"): + normalize_todo_replan_cadence_machine_defaults( + { + "schema_version": "todo_replan_cadence_machine_defaults_v0", + "completed_todos": completed_todos, + } + ) + + +@pytest.mark.parametrize("field", ["enabled", "safe_fix", "strict_receipt"]) +def test_change_quality_machine_default_requires_boolean_policy_fields( + field: str, +) -> None: + configuration: dict[str, object] = { + "schema_version": "change_quality_machine_defaults_v0", + "enabled": False, + "safe_fix": False, + "strict_receipt": False, + } + configuration[field] = 1 + with pytest.raises(TypeError, match=rf"{field} must be a boolean"): + normalize_change_quality_machine_defaults(configuration) + + +def test_machine_defaults_reach_real_history_and_change_quality_paths( + tmp_path: Path, +) -> None: + repo, registry_path, runtime_root = _fixture(tmp_path) + goal = _history_goal(registry_path, runtime_root) + assert goal["execution_profile"]["replan_after_completed_todos"] == 2 + + (repo / "app.py").write_text("value = 2\n", encoding="utf-8") + prepared = build_change_quality_prepare_packet( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=GOAL_ID, + repo_path=repo, + base_ref="HEAD", + ) + assert prepared["status"] == "review_required" + assert prepared["policy"]["enabled"] is True + + +def test_goal_overrides_win_and_clearing_restores_live_machine_defaults( + tmp_path: Path, +) -> None: + _repo, registry_path, runtime_root = _fixture(tmp_path) + configure_goal( + registry_path=registry_path, + goal_id=GOAL_ID, + execution_replan_after_todos=5, + change_quality_enabled=False, + change_quality_safe_fix=False, + change_quality_strict_receipt=False, + execute=True, + ) + overridden = _history_goal(registry_path, runtime_root) + assert completed_todo_replan_threshold(overridden["execution_profile"]) == 5 + disabled = build_change_quality_prepare_packet( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=GOAL_ID, + repo_path=_repo, + base_ref="HEAD", + ) + assert disabled["status"] == "disabled" + + cleared = configure_goal( + registry_path=registry_path, + goal_id=GOAL_ID, + clear_execution_replan_after_todos=True, + clear_change_quality_configuration=True, + execute=True, + ) + assert cleared["changed_fields"] == ["execution_profile", "control_plane"] + inherited = _history_goal(registry_path, runtime_root) + assert inherited["execution_profile"]["replan_after_completed_todos"] == 2 + inherited_quality = build_change_quality_prepare_packet( + registry_path=registry_path, + runtime_root=runtime_root, + goal_id=GOAL_ID, + repo_path=_repo, + base_ref="HEAD", + ) + assert inherited_quality["policy"]["enabled"] is True + + capabilities = { + item["capability_id"]: item + for item in cleared["configuration_catalog"]["capability_catalog"][ + "capabilities" + ] + } + assert "current" not in capabilities["todo_replan_cadence"] + assert "current" not in capabilities["change_quality_qualification"] diff --git a/tests/capabilities/test_periodic_report_machine_store.py b/tests/capabilities/test_periodic_report_machine_store.py index 03b69ba816..50414998f7 100644 --- a/tests/capabilities/test_periodic_report_machine_store.py +++ b/tests/capabilities/test_periodic_report_machine_store.py @@ -401,7 +401,11 @@ def test_canonical_machine_config_cli_uses_the_same_store_and_projection( assert main([*common, "describe"]) == 0 catalog = json.loads(capsys.readouterr().out) assert catalog["schema_version"] == "machine_configuration_catalog_v0" - assert [item["namespace"] for item in catalog["namespaces"]] == ["periodic_report"] + assert [item["namespace"] for item in catalog["namespaces"]] == [ + "change_quality_qualification", + "periodic_report", + "todo_replan_cadence", + ] assert ( main( diff --git a/tests/control_plane/test_todo_replan_cadence.py b/tests/control_plane/test_todo_replan_cadence.py index 17c58fe0c3..db574f110d 100644 --- a/tests/control_plane/test_todo_replan_cadence.py +++ b/tests/control_plane/test_todo_replan_cadence.py @@ -50,7 +50,7 @@ def test_cadence_configuration_previews_persists_and_restores_default(tmp_path): ) assert feature["current"] == {"completed_todos": 3} editor = feature["configuration_editor"] - assert editor["writable_scopes"] == ["goal"] + assert editor["writable_scopes"] == ["machine", "goal"] assert editor["fields"][0]["input_kind"] == "number" assert editor["fields"][0]["minimum"] == 1 assert editor["fields"][0]["maximum"] == 5 @@ -58,7 +58,7 @@ def test_cadence_configuration_previews_persists_and_restores_default(tmp_path): registry_path=registry, goal_id="example", execute=True, - execution_replan_after_todos=5, + clear_execution_replan_after_todos=True, ) assert ( json.loads(registry.read_text())["goals"][0]["execution_profile"] @@ -66,6 +66,21 @@ def test_cadence_configuration_previews_persists_and_restores_default(tmp_path): ) +def test_clearing_an_absent_cadence_override_is_a_noop(tmp_path): + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({"goals": [{"id": "example", "status": "active"}]})) + + result = configure_goal( + registry_path=registry, + goal_id="example", + clear_execution_replan_after_todos=True, + execute=True, + ) + + assert result["changed"] is False + assert "execution_profile" not in json.loads(registry.read_text())["goals"][0] + + def test_cli_cadence_roundtrip_syncs_to_an_isolated_runtime(tmp_path): root = Path(__file__).resolve().parents[2] registry = tmp_path / "registry.json" diff --git a/tests/test_chat_goal_configuration_api.py b/tests/test_chat_goal_configuration_api.py index 9808f50d77..19148fe205 100644 --- a/tests/test_chat_goal_configuration_api.py +++ b/tests/test_chat_goal_configuration_api.py @@ -11,6 +11,7 @@ CHAT_GOAL_CONFIGURATION_PATH, CHAT_GOAL_CONFIGURATION_PREVIEW_PATH, GoalConfigurationRequestMixin, + _goal_capability_options, ) from loopx.control_plane.goals import configure_goal_service @@ -305,6 +306,15 @@ def test_goal_configuration_merges_live_machine_defaults_without_goal_override() assert periodic["effective_configuration"]["inherited"] is True +def test_machine_inheritable_goal_capabilities_can_clear_their_overrides() -> None: + assert _goal_capability_options("todo_replan_cadence", None) == { + "clear_execution_replan_after_todos": True + } + assert _goal_capability_options("change_quality_qualification", None) == { + "clear_change_quality_configuration": True + } + + def test_goal_configuration_inspection_requires_one_goal_id() -> None: handler = _Handler(CHAT_GOAL_CONFIGURATION_PATH, _catalog_payload()) handler._goal_configuration_inspect() diff --git a/tests/test_chat_machine_configuration_api.py b/tests/test_chat_machine_configuration_api.py index 72ff8f9cbd..a544ff0f54 100644 --- a/tests/test_chat_machine_configuration_api.py +++ b/tests/test_chat_machine_configuration_api.py @@ -1,7 +1,7 @@ from __future__ import annotations -import json import http.client +import json import threading from collections.abc import Mapping from pathlib import Path @@ -193,28 +193,31 @@ def test_inspection_lists_registered_namespaces_without_local_refs( response = handler.responses[0] assert response["status"] == "absent" - assert response["available_namespaces"] == ["periodic_report"] - assert response["namespace_catalog"] == { - "schema_version": "machine_configuration_catalog_v0", - "namespaces": [ - { - "namespace": "periodic_report", - "title": "Periodic reports", - "description": ( - "Live default for Goals without an explicit periodic-report " - "override. Goal overrides remain fixed; changing or removing " - "this policy updates inherited behavior on the next plan." - ), - "schema_versions": ["periodic_report_machine_defaults_v0"], - "configuration_template": { - "schema_version": "periodic_report_machine_defaults_v0", - "enabled": False, - "inheritance": "live_machine_default", - "timezone": "UTC", - }, - "template_status": "ready", - } - ], + assert response["available_namespaces"] == [ + "change_quality_qualification", + "periodic_report", + "todo_replan_cadence", + ] + namespace_catalog = { + item["namespace"]: item for item in response["namespace_catalog"]["namespaces"] + } + assert namespace_catalog["periodic_report"]["configuration_template"] == { + "schema_version": "periodic_report_machine_defaults_v0", + "enabled": False, + "inheritance": "live_machine_default", + "timezone": "UTC", + } + assert namespace_catalog["todo_replan_cadence"]["configuration_template"] == { + "schema_version": "todo_replan_cadence_machine_defaults_v0", + "completed_todos": 5, + } + assert namespace_catalog["change_quality_qualification"][ + "configuration_template" + ] == { + "schema_version": "change_quality_machine_defaults_v0", + "enabled": False, + "safe_fix": False, + "strict_receipt": False, } capability_catalog = response["capability_catalog"] assert capability_catalog["schema_version"] == "capability_configuration_catalog_v0" @@ -227,7 +230,7 @@ def test_inspection_lists_registered_namespaces_without_local_refs( assert capability["machine_namespace"] == "periodic_report" assert ( capability["default"] - == response["namespace_catalog"]["namespaces"][0]["configuration_template"] + == namespace_catalog["periodic_report"]["configuration_template"] ) assert capability["configuration_editor"]["supported_scopes"] == [ "machine", @@ -274,11 +277,19 @@ def test_machine_catalog_discovers_goal_features_without_granting_machine_writes for capability_id, item in machine.items(): assert "current" not in item assert "commands" not in item - if capability_id != "periodic_report": + if capability_id not in { + "periodic_report", + "todo_replan_cadence", + "change_quality_qualification", + }: assert item["available_scopes"] == ["goal"] assert "machine_namespace" not in item assert "machine" not in item["configuration_editor"]["writable_scopes"] assert item["effective_configuration"]["source"] == "not_configured" + else: + assert item["available_scopes"] == ["machine", "goal"] + assert item["machine_namespace"] == capability_id + assert "machine" in item["configuration_editor"]["writable_scopes"] rejected = _Handler( tmp_path, From 4e87b2f624889f9adfbc1fa28259f329a3c46ada Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 01:59:15 +0800 Subject: [PATCH 2/3] feat: expose inherited policies in machine settings Signed-off-by: huangruiteng --- .../capability-localization.ts | 2 + .../src/features/personal-workspace/i18n.tsx | 12 +- .../machine-configuration-settings.tsx | 14 ++ .../personal-workspace-browser/fixture.mjs | 115 ++++++++++++++--- .../typed-actions.mjs | 13 +- loopx/web/chat/assets/index-C-0JyRzm.css | 1 + loopx/web/chat/assets/index-CwxDMCrv.js | 121 ++++++++++++++++++ 7 files changed, 257 insertions(+), 21 deletions(-) create mode 100644 loopx/web/chat/assets/index-C-0JyRzm.css create mode 100644 loopx/web/chat/assets/index-CwxDMCrv.js 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 6d3c5c53cd..02510ae152 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -105,6 +105,7 @@ const capabilityCopy: Record> = { const fieldCopy: Record = { en: { + completed_todos: { label: "Completed Todos between Goal reviews", description: "Machine default or explicit Goal override, from 1 to 5." }, allowed_domains: { label: "Allowed responsibility domains", description: "Enter one bounded, public-safe domain per line." }, coordinator_agent_id: { label: "Coordinator Agent", description: "Use an already registered Agent id; leave blank to disable coordination." }, enabled: { label: "Enabled" }, @@ -119,6 +120,7 @@ const fieldCopy: Record = { timezone: { label: "Timezone", description: "Use an IANA timezone, for example Asia/Shanghai." }, }, "zh-CN": { + completed_todos: { label: "两次 Goal 复核间的已完成 Todo 数", description: "可设置 1–5;机器默认值可被 Goal 显式覆盖。" }, allowed_domains: { label: "允许的职责域", description: "每行填写一个有边界、可公开的职责域。" }, coordinator_agent_id: { label: "协调 Agent", description: "填写一个已经注册的 Agent ID;留空表示关闭协调。" }, enabled: { label: "启用" }, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index b0439d2e57..a660189f9d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -770,7 +770,7 @@ const en = { "machine.editorUnavailableDescription": "The namespace remains visible in the registry. Install or upgrade its Dashboard editor before changing it.", "machine.editorMode": "Editor mode", "machine.liveDefault": "Live default; Goal override wins", - "machine.liveDefaultDescription": "Goals without an explicit periodic-report override read this machine policy on every plan. Changes and removal affect those existing Goals immediately; an explicit Goal override stays pinned.", + "machine.liveDefaultDescription": "Goals without an explicit override read the current machine policy at the capability’s next decision point. Changes and removal affect those existing Goals immediately; an explicit Goal override stays pinned.", "machine.genericNamespaceDescription": "Edit this registered namespace as JSON. LoopX validates it with the capability-owned schema before previewing any write.", "machine.jsonConfiguration": "Namespace configuration (JSON)", "machine.jsonConfigurationHelp": "Only this namespace is updated. Other machine configuration is preserved, and Apply remains locked to the reviewed preview revision.", @@ -789,6 +789,10 @@ const en = { "machine.periodicReportDescription": "Default report policy for every Goal without an explicit override. Goal scheduling and delivery consume the effective configuration.", "machine.periodicReportActivation": "Enabled means automatic delivery at validated stage boundaries", "machine.periodicReportActivationDescription": "This is not a weekly timer. When LoopX validates a Goal stage completion, an Agent prepares and freezes the report, then automatically delivers it through the configured Goal Channel. The enabled subscription is standing delivery authority; failures and route drift stop closed for repair.", + "machine.changeQualityActivation": "Qualification is a quality gate, not new authority", + "machine.changeQualityActivationDescription": "Goals that inherit this policy qualify their exact final diff. safe_fix allows at most one bounded fix pass inside existing write authority; this setting never grants file, permission, or merge authority.", + "machine.replanCadenceActivation": "Review cadence changes timing, not execution authority", + "machine.replanCadenceActivationDescription": "Goals without an explicit override read this threshold before the next review decision. It does not create Turns, spend quota, or grant authority.", "machine.preview": "Review exact machine change", "machine.previewChanges": "Preview changes", "machine.previewError": "A machine-policy preview could not be created.", @@ -1735,7 +1739,7 @@ const zhCN: Record = { "machine.editorUnavailableDescription": "它仍会显示在 Registry 中;安装或升级对应的 Dashboard 编辑器后才能修改。", "machine.editorMode": "编辑模式", "machine.liveDefault": "实时默认值;Goal 显式覆盖优先", - "machine.liveDefaultDescription": "没有显式周期报告覆盖的 Goal 会在每次规划时读取当前机器策略。修改或移除策略会立即影响这些已有 Goal;显式 Goal 覆盖保持固定。", + "machine.liveDefaultDescription": "没有显式覆盖的 Goal 会在该能力下一次决策时读取当前机器策略。修改或移除策略会立即影响这些已有 Goal;显式 Goal 覆盖保持固定。", "machine.genericNamespaceDescription": "使用 JSON 编辑这个已注册 Namespace。LoopX 会先按 capability 自己拥有的 schema 校验,再允许预览写入。", "machine.jsonConfiguration": "Namespace 配置(JSON)", "machine.jsonConfigurationHelp": "只更新当前 Namespace;其他机器配置会保留,Apply 仍锁定到已审阅的 Preview Revision。", @@ -1754,6 +1758,10 @@ const zhCN: Record = { "machine.periodicReportDescription": "为所有未显式覆盖的 Goal 提供默认报告策略;Goal 调度与发送消费解析后的有效配置。", "machine.periodicReportActivation": "开启后将在已验证的阶段节点自动投递", "machine.periodicReportActivationDescription": "这不是每周定时器。当 LoopX 验证 Goal 已完成一个阶段时,Agent 会生成并冻结报告,随后通过配置的 Goal Channel 自动发送。启用此订阅即授予持续投递权;发送失败或路由漂移会 fail closed 并进入修复。", + "machine.changeQualityActivation": "质量验证是质量门禁,不是新增授权", + "machine.changeQualityActivationDescription": "继承该策略的 Goal 会验证最终精确 diff。safe_fix 只允许在既有写入权限内执行至多一次有界修复;此设置不会授予文件、权限或合并权。", + "machine.replanCadenceActivation": "复核周期只改变时机,不改变执行权限", + "machine.replanCadenceActivationDescription": "没有显式覆盖的 Goal 会在下一次复核决策前读取此阈值;它不会创建 Turn、消耗配额或授予权限。", "machine.preview": "审阅机器配置变更", "machine.previewChanges": "预览变更", "machine.previewError": "无法生成机器策略预览。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx index fd89f499a1..cf733c962e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx @@ -305,6 +305,20 @@ export function MachineConfigurationSettings() { ) : null} + {selected.capability_id === "change_quality_qualification" ? ( +
+ +
{t("machine.changeQualityActivation")}

{t("machine.changeQualityActivationDescription")}

+
+ ) : null} + + {selected.capability_id === "todo_replan_cadence" ? ( +
+ +
{t("machine.replanCadenceActivation")}

{t("machine.replanCadenceActivationDescription")}

+
+ ) : null} + {editorAvailable ? <>{editorMode === "json" || !selected.configuration_editor.fields.some((field) => field.key === "enabled" && field.input_kind === "boolean") ?