diff --git a/skillopt_sleep/cycle.py b/skillopt_sleep/cycle.py index f1d7bf02..4ca5dc13 100644 --- a/skillopt_sleep/cycle.py +++ b/skillopt_sleep/cycle.py @@ -988,8 +988,24 @@ def run_sleep_cycle( adopted_paths: List[str] = [] if not dry_run: _progress(cfg, "staging start") - proposed_skill = result.new_skill if (cfg.get("evolve_skill") and result.accepted) else None - proposed_memory = result.new_memory if (cfg.get("evolve_memory") and result.accepted) else None + proposed_skill = ( + result.new_skill + if ( + cfg.get("evolve_skill") + and result.accepted + and result.new_skill != skill + ) + else None + ) + proposed_memory = ( + result.new_memory + if ( + cfg.get("evolve_memory") + and result.accepted + and result.new_memory != memory + ) + else None + ) skill_proposals, skip_notes = _skill_proposals_from_groups( cfg, group_outcomes, diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py index 3f975361..f4193188 100644 --- a/tests/test_sleep_engine.py +++ b/tests/test_sleep_engine.py @@ -1473,6 +1473,114 @@ def test_cycle_pins_the_exact_managed_skill_and_memory_bytes_it_read(self): os.path.realpath(memory_path), ) + def _assert_only_changed_documents_are_staged( + self, new_skill, new_memory, expect_skill, expect_memory + ): + from skillopt_sleep.consolidate import ConsolidationResult + + skill = "# managed baseline\nrule\n" + memory = "# memory baseline\npreference\n" + with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: + target = os.path.join(proj, ".agents", "skills", "taste", "SKILL.md") + memory_path = os.path.join(proj, "CLAUDE.md") + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8") as handle: + handle.write(skill) + with open(memory_path, "w", encoding="utf-8") as handle: + handle.write(memory) + cfg = load_config( + invoked_project=proj, + projects="invoked", + backend="mock", + claude_home=os.path.join(home, ".claude"), + target_skill_path=target, + auto_adopt=False, + ) + applied = [] + if new_skill != skill: + applied.append(EditRecord("skill", "add", "sharpened rule")) + if new_memory != memory: + applied.append(EditRecord("memory", "add", "learned preference")) + result = ConsolidationResult( + accepted=True, + gate_action="accept_new_best", + baseline_score=0.1, + candidate_score=0.2, + new_skill=new_skill, + new_memory=new_memory, + applied_edits=applied, + rejected_edits=[], + holdout_baseline=0.1, + holdout_candidate=0.2, + ) + tasks = assign_splits( + researcher_persona(), holdout_fraction=0.34, seed=42 + ) + + with mock.patch( + "skillopt_sleep.cycle.dream_consolidate", + return_value=result, + ): + outcome = run_sleep_cycle(cfg, seed_tasks=tasks) + + # Staging never edits the live documents; adoption stays explicit. + with open(target, encoding="utf-8") as handle: + self.assertEqual(handle.read(), skill) + with open(memory_path, encoding="utf-8") as handle: + self.assertEqual(handle.read(), memory) + + with open( + os.path.join(outcome.staging_dir, "manifest.json"), + encoding="utf-8", + ) as handle: + manifest = json.load(handle) + # Manifest flags and artifact presence have to agree; a flag without + # its file (or a file without its flag) would break adoption. + self.assertEqual(manifest["has_managed_skill"], expect_skill) + self.assertEqual(manifest["has_managed_memory"], expect_memory) + self.assertEqual( + os.path.exists( + os.path.join(outcome.staging_dir, "proposed_SKILL.md") + ), + expect_skill, + ) + self.assertEqual( + os.path.exists( + os.path.join(outcome.staging_dir, "proposed_CLAUDE.md") + ), + expect_memory, + ) + + def test_cycle_stages_only_documents_that_changed(self): + # The staging contract is byte/text equality, not semantic or whitespace + # normalized comparison: an accepted cycle proposes a document only when it + # actually rewrote it. Covered for every shape an accepted result can take, + # so a symmetric regression on the skill side cannot hide behind the + # memory-only case. + skill = "# managed baseline\nrule\n" + memory = "# memory baseline\npreference\n" + new_skill = skill + "prefer the shortest reproduction\n" + new_memory = memory + "learned preference\n" + cases = ( + ("neither_changed", skill, memory, False, False), + ("skill_only", new_skill, memory, True, False), + ("memory_only", skill, new_memory, False, True), + ("both_changed", new_skill, new_memory, True, True), + # Whitespace-only is a real change under a byte-equality contract, so it + # is a positive case. If this ever fails, the comparison has started + # normalizing and the documented contract has silently moved. + ("whitespace_only_skill", skill + "\n", memory, True, False), + ("whitespace_only_memory", skill, memory + " \n", False, True), + ) + for name, candidate_skill, candidate_memory, expect_skill, expect_memory in cases: + with self.subTest(case=name): + self._assert_only_changed_documents_are_staged( + candidate_skill, + candidate_memory, + expect_skill, + expect_memory, + ) + def test_managed_skill_change_during_consolidation_refuses_the_night(self): from skillopt_sleep.consolidate import ConsolidationResult from skillopt_sleep.staging import StagingError, latest_staging