diff --git a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts index 4fe5762107..0331c8d532 100644 --- a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts +++ b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js"; +import { goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, goalStorageEditorValue, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../src/data/capability-configuration.js"; const periodicReportEditor = { fields: [ @@ -29,6 +29,29 @@ assert.deepEqual(replanCadenceEditorDefaults( { schema_version: "todo_replan_cadence_machine_defaults_v0", completed_todos: 4 }, cadenceDefault), { count_unit: "completed_todos", count: 4, stride_strategy: "off" }, "legacy effective intervals retain their own unit and count"); +const inheritedReview = { count_unit: "effective_turns", count: 3, stride_strategy: "off" }; +assert.deepEqual(goalReplanCadenceEditorValue({ stride_strategy: "evidence_conditioned_v1" }, inheritedReview), + { ...inheritedReview, stride_strategy: "evidence_conditioned_v1" }, + "strategy-only Goal settings display the live review interval"); +assert.deepEqual(goalReplanCadenceWriteValue({ ...inheritedReview, stride_strategy: "evidence_conditioned_v1" }, false), + { stride_strategy: "evidence_conditioned_v1" }, + "changing only the strategy must not pin the displayed interval"); +assert.deepEqual(goalReplanCadenceWriteValue({ ...inheritedReview, count: 2, stride_strategy: "evidence_conditioned_v1" }, true), + { count_unit: "effective_turns", count: 2, stride_strategy: "evidence_conditioned_v1" }, + "an edited interval remains an explicit Goal override"); +assert.deepEqual(goalReplanCadenceWriteValue({ count_unit: "completed_todos", count: 2, stride_strategy: "off" }, + false), + { stride_strategy: "off" }, + "legacy device units can be inherited without pinning their count"); +assert.equal(hasReplanCadenceOverride({ stride_strategy: "evidence_conditioned_v1" }), false); +assert.equal(hasReplanCadenceOverride({ count_unit: "effective_turns", count: 3 }), true); +assert.equal(hasReplanCadenceOverride({ completed_todos: 2 }), true, "legacy pinned cadences stay explicit"); +assert.deepEqual(goalReplanCadenceWriteValue({ ...inheritedReview, stride_strategy: "evidence_conditioned_v1" }, true), + { ...inheritedReview, stride_strategy: "evidence_conditioned_v1" }, + "an explicit interval remains pinned even when it matches the machine policy"); +assert.deepEqual(goalReplanCadenceWriteValue({ count_unit: "effective_turns", count: 2, stride_strategy: "evidence_conditioned_v1" }, false), + { stride_strategy: "evidence_conditioned_v1" }, + "removing JSON interval fields clears only the cadence, including a previously pinned value"); const storageDefault = { schema_version: "loopx_goal_storage_defaults_v1", new_goal_provider: "sqlite", canonical_creation: true, new_goal_handoff_mode: "hard_lease" }; assert.deepEqual(projectEditableCapabilityConfiguration(storageEditor, diff --git a/apps/presentation/dashboard/src/data/capability-configuration.ts b/apps/presentation/dashboard/src/data/capability-configuration.ts index d0aa2a9853..9637edd3e5 100644 --- a/apps/presentation/dashboard/src/data/capability-configuration.ts +++ b/apps/presentation/dashboard/src/data/capability-configuration.ts @@ -71,6 +71,34 @@ export function replanCadenceEditorDefaults(effective: unknown, fallback: unknow return defaults; } +/** Show the live interval when a Goal stores only its strategy choice. */ +export function goalReplanCadenceEditorValue(current: unknown, effective: unknown): Record { + const inherited = configurationObject(replanCadenceEditorValue(effective)); + return current == null ? inherited : { + ...inherited, + ...configurationObject(replanCadenceEditorValue(current)), + }; +} + +/** Persisted or explicit JSON interval fields represent a Goal override. */ +export function hasReplanCadenceOverride(value: unknown): boolean { + const record = configurationObject(value); + return ["count_unit", "count", "completed_todos"].some((key) => Object.hasOwn(record, key)); +} + +/** Displaying an inherited interval must not turn it into a pinned write. */ +export function goalReplanCadenceWriteValue( + draft: Record, + cadenceOverride: boolean, +): Record { + const value = { ...draft }; + if (!cadenceOverride) { + delete value.count_unit; + delete value.count; + } + return value; +} + /** A v0 storage selector did not enable creation; opening Settings must not. */ export function goalStorageEditorValue(value: unknown): unknown { const record = configurationObject(value); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 84ff657a9c..5cdabd5272 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1717,7 +1717,7 @@ export const capabilityConfigurationCatalogSchema = z.object({ available_scopes: z.array(z.enum(["goal", "machine"])), machine_namespace: z.string().optional(), goal_feature_id: z.string().optional(), - effective_value_policy: z.literal("goal_override_over_live_machine_default").optional(), + effective_value_policy: z.enum(["goal_override_over_live_machine_default", "goal_stride_over_live_review_cadence"]).optional(), availability: z.string().optional(), default: z.record(z.string(), z.unknown()).optional(), current: z.record(z.string(), z.unknown()).optional(), 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 6cc206f989..764128ed9b 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 @@ -10,7 +10,7 @@ import { type GoalConfigurationPreview, type GoalConfigurationPartialWrite, } from "../../data/chat"; -import { parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults, replanCadenceEditorValue } from "../../data/capability-configuration"; +import { goalReplanCadenceEditorValue, goalReplanCadenceWriteValue, hasReplanCadenceOverride, parseEditableCapabilityJson, projectEditableCapabilityConfiguration, replanCadenceEditorDefaults } from "../../data/capability-configuration"; import { useWorkspaceI18n } from "./i18n"; import { CapabilityConfigurationFields } from "./capability-configuration-fields"; import { withReportScheduleTimezone } from "./periodic-report-schedule-field"; @@ -32,15 +32,19 @@ type CapabilityCatalogProps = Readonly<{ type CapabilityMutationState = Readonly<{ busy: "preview" | "apply" | null; draft: Record; + cadenceOverride: boolean; partialWrite: GoalConfigurationPartialWrite | null; preview: GoalConfigurationPreview | null; }>; -function goalWriteDraft(selected: CapabilityConfigurationCatalog["capabilities"][number], draft: Record) { +function goalWriteDraft(selected: CapabilityConfigurationCatalog["capabilities"][number], draft: Record, editorMode: "guided" | "json", cadenceOverride: boolean) { const defaults = selected.capability_id === "todo_replan_cadence" - ? replanCadenceEditorDefaults(selected.effective_configuration?.configuration, selected.default) + ? editorMode === "json" ? undefined : replanCadenceEditorDefaults(selected.effective_configuration?.configuration, selected.default) : selected.default; const writable = projectEditableCapabilityConfiguration(selected.configuration_editor, draft, defaults); + if (selected.capability_id === "todo_replan_cadence" && editorMode === "guided") { + return goalReplanCadenceWriteValue(writable, cadenceOverride); + } if (selected.capability_id === "pull_request_review" && !Object.hasOwn(selected.current ?? {}, "owner_logins") && JSON.stringify(writable.owner_logins ?? []) === JSON.stringify(selected.effective_configuration?.configuration?.owner_logins ?? [])) { delete writable.owner_logins; @@ -65,6 +69,7 @@ function useCapabilityMutation({ goalId, onApplied, selected, t }: Readonly<{ const [mutation, setMutation] = useState({ busy: null, draft: {}, + cadenceOverride: false, partialWrite: null, preview: null, }); @@ -86,9 +91,12 @@ function useCapabilityMutation({ goalId, onApplied, selected, t }: Readonly<{ busy: null, draft: projectEditableCapabilityConfiguration( selected?.configuration_editor ?? { fields: [] }, - selected?.capability_id === "todo_replan_cadence" ? replanCadenceEditorValue(current) : current, + selected?.capability_id === "todo_replan_cadence" + ? goalReplanCadenceEditorValue(selected.current, selected.effective_configuration?.configuration) + : current, selected?.default, ), + cadenceOverride: hasReplanCadenceOverride(selected?.current), partialWrite: null, preview: null, }); @@ -100,7 +108,7 @@ function useCapabilityMutation({ goalId, onApplied, selected, t }: Readonly<{ setMutation((current) => ({ ...current, busy: "preview", partialWrite: null })); setError(null); try { - const nextPreview = await previewGoalConfiguration(goalId, selected.capability_id, configuration === null ? null : goalWriteDraft(selected, configuration)); + const nextPreview = await previewGoalConfiguration(goalId, selected.capability_id, configuration === null ? null : goalWriteDraft(selected, configuration, editorMode, mutation.cadenceOverride)); setMutation((current) => ({ ...current, preview: nextPreview })); } catch (reason) { setError(reason instanceof Error ? reason.message : t("capabilities.previewFailed")); @@ -114,7 +122,7 @@ function useCapabilityMutation({ goalId, onApplied, selected, t }: Readonly<{ setMutation((current) => ({ ...current, busy: "apply" })); setError(null); try { - const writableDraft = goalWriteDraft(selected, mutation.draft); + const writableDraft = goalWriteDraft(selected, mutation.draft, editorMode, mutation.cadenceOverride); const result = await applyGoalConfiguration( goalId, selected.capability_id, @@ -141,6 +149,7 @@ function useCapabilityMutation({ goalId, onApplied, selected, t }: Readonly<{ draft: selected?.capability_id === "periodic_report" ? withReportScheduleTimezone(current.draft, key, value) : { ...current.draft, [key]: value }, + cadenceOverride: current.cadenceOverride || (selected?.capability_id === "todo_replan_cadence" && (key === "count_unit" || key === "count")), preview: null, })); setError(null); @@ -152,16 +161,22 @@ function useCapabilityMutation({ goalId, onApplied, selected, t }: Readonly<{ const parsed = parseEditableCapabilityJson(selected.configuration_editor, text); setMutation((current) => ({ ...current, preview: null, draft: parsed ? projectEditableCapabilityConfiguration(selected.configuration_editor, parsed, - selected.capability_id === "todo_replan_cadence" - ? replanCadenceEditorDefaults(selected.effective_configuration?.configuration, selected.default) - : selected.default) - : current.draft })); + selected.capability_id === "todo_replan_cadence" ? undefined : selected.default) + : current.draft, + cadenceOverride: selected.capability_id === "todo_replan_cadence" && parsed + ? hasReplanCadenceOverride(parsed) : current.cadenceOverride, + })); setError(null); } function changeMode() { if (mutation.busy || !jsonValid) return; - if (editorMode === "guided") setJsonDraft(JSON.stringify(mutation.draft, null, 2)); + if (editorMode === "guided" && selected) { + const jsonValue = selected.capability_id === "todo_replan_cadence" + ? goalWriteDraft(selected, mutation.draft, editorMode, mutation.cadenceOverride) + : mutation.draft; + setJsonDraft(JSON.stringify(jsonValue, null, 2)); + } setEditorMode(editorMode === "guided" ? "json" : "guided"); setMutation((current) => ({ ...current, preview: null, draft: editorMode === "json" && selected?.capability_id === "todo_replan_cadence" @@ -255,12 +270,7 @@ function CapabilityCatalog({ callbacks, catalog, goalId, notification, onApplied async function createPreview() { if (!localizedSelected || !editorAvailable || busy || !jsonValid) return; - const writableDraft = projectEditableCapabilityConfiguration( - localizedSelected.configuration_editor, - draft, - localizedSelected.default, - ); - await requestPreview(writableDraft); + await requestPreview(draft); } async function createClearPreview() { @@ -351,6 +361,11 @@ function CapabilityCatalog({ callbacks, catalog, goalId, notification, onApplied ) : null} {editorMode === "guided" ?
+ {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.`} +

: null} None: capability_default = ( entry.get("default") if isinstance(entry.get("default"), Mapping) else None ) + if ( + entry["capability_id"] == "todo_replan_cadence" + and goal_override is not None + and set(goal_override) == {"stride_strategy"} + ): + from .todo_replan_cadence.machine_defaults import ( + cadence_configuration_value, + default_replan_cadence_configuration, + ) + + # The Goal chooses the strategy while the existing review cadence stays + # live. Keep `current` sparse so readback does not claim a pinned count. + inherited_cadence = ( + cadence_configuration_value(machine_default) + if machine_default is not None + else default_replan_cadence_configuration() + ) + goal_override = { + "count_unit": inherited_cadence["count_unit"], + "count": inherited_cadence["count"], + **goal_override, + } + entry["effective_value_policy"] = "goal_stride_over_live_review_cadence" entry["effective_configuration"] = resolve_capability_configuration( str(entry["capability_id"]), goal_override=goal_override, diff --git a/loopx/capabilities/todo_replan_cadence/goal_configuration.py b/loopx/capabilities/todo_replan_cadence/goal_configuration.py index 92550ae244..4267f92751 100644 --- a/loopx/capabilities/todo_replan_cadence/goal_configuration.py +++ b/loopx/capabilities/todo_replan_cadence/goal_configuration.py @@ -25,7 +25,7 @@ def configuration_summary(goal: Mapping[str, Any]) -> dict[str, Any] | None: "count": completed_todo_replan_threshold(dict(profile)), **strategy, } - return None + return strategy or None __all__ = ["configuration_summary"] diff --git a/loopx/capabilities/todo_replan_cadence/machine_defaults.py b/loopx/capabilities/todo_replan_cadence/machine_defaults.py index e281494cfd..e0b171e481 100644 --- a/loopx/capabilities/todo_replan_cadence/machine_defaults.py +++ b/loopx/capabilities/todo_replan_cadence/machine_defaults.py @@ -175,7 +175,7 @@ def apply_todo_replan_cadence_machine_default( else "replan_after_completed_todos" ) profile[field] = cadence["count"] - if cadence.get("stride_strategy", "off") != "off": + if "stride_strategy" not in profile and cadence.get("stride_strategy", "off") != "off": profile["stride_strategy"] = cadence["stride_strategy"] projected["execution_profile"] = profile return projected diff --git a/loopx/chat_goal_configuration_api.py b/loopx/chat_goal_configuration_api.py index 30b6b5a371..1dcb55ba6f 100644 --- a/loopx/chat_goal_configuration_api.py +++ b/loopx/chat_goal_configuration_api.py @@ -243,6 +243,16 @@ def _goal_capability_options( return {"capability_improvement_configuration": config} 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"}: + # 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"]), + "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") diff --git a/loopx/execution_profile.py b/loopx/execution_profile.py index 7ead8148aa..e2beb5e700 100644 --- a/loopx/execution_profile.py +++ b/loopx/execution_profile.py @@ -157,8 +157,6 @@ def compact_execution_profile(value: Any) -> dict[str, Any]: 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"]) - if not any(key in value for key in ("replan_after_effective_turns", "replan_after_completed_todos")): - raise ValueError("a Goal stride strategy requires an explicit review cadence in the same profile") if "replan_after_effective_turns" in value and "replan_after_completed_todos" in value: raise ValueError("choose one review cadence unit") @@ -291,11 +289,6 @@ def apply_goal_execution_profile_change( profile.pop("replan_after_completed_todos", None) if clear_replan_after_completed_todos: profile.pop("replan_after_completed_todos", None) - if stride_strategy is None and (clear_replan_after_completed_todos or clear_replan_after_effective_turns) and not any( - key in profile for key in ("replan_after_effective_turns", "replan_after_completed_todos") - ): - # This capability is one review configuration, not per-field inheritance. - profile.pop("stride_strategy", None) goal["execution_profile"] = configure_execution_profile( profile, turn_granularity=turn_granularity, diff --git a/tests/capabilities/test_machine_configuration_goal_defaults.py b/tests/capabilities/test_machine_configuration_goal_defaults.py index 49e9ce0b69..408f882e80 100644 --- a/tests/capabilities/test_machine_configuration_goal_defaults.py +++ b/tests/capabilities/test_machine_configuration_goal_defaults.py @@ -331,7 +331,7 @@ def test_product_default_and_namespace_removal_use_six_settled_turns(tmp_path, m assert _history_goal(registry_path, runtime_root)["execution_profile"]["replan_after_effective_turns"] == 6 -def test_stride_version_inherits_as_one_review_configuration_and_rolls_back(tmp_path: Path) -> None: +def test_stride_version_inherits_and_rolls_back(tmp_path: Path) -> None: from loopx.chat_goal_configuration_api import _goal_capability_options from loopx.capabilities.goal_inspection import inspect_goal_capabilities @@ -350,7 +350,7 @@ def test_stride_version_inherits_as_one_review_configuration_and_rolls_back(tmp_ 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"]["stride_strategy"] == "evidence_conditioned_v1" - # A legacy Goal override pins the whole review configuration, including off. + # A cadence-only Goal override retains the previous default-off meaning. configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execution_replan_after_turns=3, execute=True) assert "stride_strategy" not in _history_goal(registry_path, runtime_root)["execution_profile"] @@ -391,6 +391,86 @@ def test_stride_version_inherits_as_one_review_configuration_and_rolls_back(tmp_ assert registry_path.read_bytes() == before +def test_goal_stride_only_uses_live_review_interval(tmp_path: Path) -> None: + 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) + options = _goal_capability_options("todo_replan_cadence", { + "stride_strategy": "evidence_conditioned_v1", + }) + assert options == { + "execution_stride_strategy": "evidence_conditioned_v1", + "clear_execution_replan_after_turns": True, + "clear_execution_replan_after_todos": True, + } + before = registry_path.read_bytes() + preview = configure_goal(registry_path=registry_path, goal_id=GOAL_ID, **options) + assert preview["ok"] and preview["changed"] + assert registry_path.read_bytes() == before + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, **options) + raw = json.loads(registry_path.read_text())["goals"][0]["execution_profile"] + assert raw["stride_strategy"] == "evidence_conditioned_v1" + assert "replan_after_effective_turns" not in raw + assert "replan_after_completed_todos" not in raw + inherited = _history_goal(registry_path, runtime_root)["execution_profile"] + assert inherited["replan_after_completed_todos"] == 2 + assert inherited["stride_strategy"] == "evidence_conditioned_v1" + + def review() -> dict[str, object]: + inspection = inspect_goal_capabilities( + registry_path=registry_path, runtime_root=runtime_root, goal_id=GOAL_ID, + )["configuration"] + return next(row for row in inspection["capability_catalog"]["capabilities"] + if row["capability_id"] == "todo_replan_cadence") + + first = review() + assert first["current"] == {"stride_strategy": "evidence_conditioned_v1"} + assert first["effective_configuration"]["configuration"] == { + "count_unit": "completed_todos", "count": 2, + "stride_strategy": "evidence_conditioned_v1", + } + + machine_registry = build_builtin_machine_configuration_registry() + machine = _machine_configuration() + machine["namespaces"]["todo_replan_cadence"] = { + "schema_version": "todo_replan_cadence_machine_defaults_v2", + "count_unit": "effective_turns", "count": 4, "stride_strategy": "off", + } + preview = configure_machine_configuration( + runtime_root=runtime_root, registry=machine_registry, configuration=machine, + ) + configure_machine_configuration( + runtime_root=runtime_root, registry=machine_registry, configuration=machine, + execute=True, expected_plan_revision=preview["plan_revision"], + ) + projected = _history_goal(registry_path, runtime_root)["execution_profile"] + assert projected["replan_after_effective_turns"] == 4 + assert projected["stride_strategy"] == "evidence_conditioned_v1" + second = review() + assert second["current"] == first["current"] + assert second["effective_configuration"]["configuration"]["count"] == 4 + assert second["effective_configuration"]["effective_revision"] != first["effective_configuration"]["effective_revision"] + + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, + execution_replan_after_turns=3, execute=True) + assert _history_goal(registry_path, runtime_root)["execution_profile"]["replan_after_effective_turns"] == 3 + assert _history_goal(registry_path, runtime_root)["execution_profile"]["stride_strategy"] == "evidence_conditioned_v1" + # Strategy-only capability JSON replaces its interval override; the CLI's + # strategy-only patch above does not silently clear a stored interval. + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, execute=True, **options) + raw = json.loads(registry_path.read_text())["goals"][0]["execution_profile"] + assert "replan_after_effective_turns" not in raw + assert raw["stride_strategy"] == "evidence_conditioned_v1" + assert _history_goal(registry_path, runtime_root)["execution_profile"]["replan_after_effective_turns"] == 4 + + configure_goal(registry_path=registry_path, goal_id=GOAL_ID, + execution_stride_strategy="off", execute=True) + assert _history_goal(registry_path, runtime_root)["execution_profile"]["stride_strategy"] == "off" + with pytest.raises(ValueError, match="stride_strategy"): + _goal_capability_options("todo_replan_cadence", {"stride_strategy": "unknown"}) + + @pytest.mark.parametrize("schema_version, strategy", [ ("todo_replan_cadence_machine_defaults_v1", "evidence_conditioned_v1"), ("todo_replan_cadence_machine_defaults_v2", "future_v2"),