@@ -1564,6 +1564,114 @@ def test_cycle_pins_the_exact_managed_skill_and_memory_bytes_it_read(self):
15641564 os .path .realpath (memory_path ),
15651565 )
15661566
1567+ def _assert_only_changed_documents_are_staged (
1568+ self , new_skill , new_memory , expect_skill , expect_memory
1569+ ):
1570+ from skillopt_sleep .consolidate import ConsolidationResult
1571+
1572+ skill = "# managed baseline\n rule\n "
1573+ memory = "# memory baseline\n preference\n "
1574+ with tempfile .TemporaryDirectory () as proj , tempfile .TemporaryDirectory () as home :
1575+ target = os .path .join (proj , ".agents" , "skills" , "taste" , "SKILL.md" )
1576+ memory_path = os .path .join (proj , "CLAUDE.md" )
1577+ os .makedirs (os .path .dirname (target ), exist_ok = True )
1578+ with open (target , "w" , encoding = "utf-8" ) as handle :
1579+ handle .write (skill )
1580+ with open (memory_path , "w" , encoding = "utf-8" ) as handle :
1581+ handle .write (memory )
1582+ cfg = load_config (
1583+ invoked_project = proj ,
1584+ projects = "invoked" ,
1585+ backend = "mock" ,
1586+ claude_home = os .path .join (home , ".claude" ),
1587+ target_skill_path = target ,
1588+ auto_adopt = False ,
1589+ )
1590+ applied = []
1591+ if new_skill != skill :
1592+ applied .append (EditRecord ("skill" , "add" , "sharpened rule" ))
1593+ if new_memory != memory :
1594+ applied .append (EditRecord ("memory" , "add" , "learned preference" ))
1595+ result = ConsolidationResult (
1596+ accepted = True ,
1597+ gate_action = "accept_new_best" ,
1598+ baseline_score = 0.1 ,
1599+ candidate_score = 0.2 ,
1600+ new_skill = new_skill ,
1601+ new_memory = new_memory ,
1602+ applied_edits = applied ,
1603+ rejected_edits = [],
1604+ holdout_baseline = 0.1 ,
1605+ holdout_candidate = 0.2 ,
1606+ )
1607+ tasks = assign_splits (
1608+ researcher_persona (), holdout_fraction = 0.34 , seed = 42
1609+ )
1610+
1611+ with mock .patch (
1612+ "skillopt_sleep.cycle.dream_consolidate" ,
1613+ return_value = result ,
1614+ ):
1615+ outcome = run_sleep_cycle (cfg , seed_tasks = tasks )
1616+
1617+ # Staging never edits the live documents; adoption stays explicit.
1618+ with open (target , encoding = "utf-8" ) as handle :
1619+ self .assertEqual (handle .read (), skill )
1620+ with open (memory_path , encoding = "utf-8" ) as handle :
1621+ self .assertEqual (handle .read (), memory )
1622+
1623+ with open (
1624+ os .path .join (outcome .staging_dir , "manifest.json" ),
1625+ encoding = "utf-8" ,
1626+ ) as handle :
1627+ manifest = json .load (handle )
1628+ # Manifest flags and artifact presence have to agree; a flag without
1629+ # its file (or a file without its flag) would break adoption.
1630+ self .assertEqual (manifest ["has_managed_skill" ], expect_skill )
1631+ self .assertEqual (manifest ["has_managed_memory" ], expect_memory )
1632+ self .assertEqual (
1633+ os .path .exists (
1634+ os .path .join (outcome .staging_dir , "proposed_SKILL.md" )
1635+ ),
1636+ expect_skill ,
1637+ )
1638+ self .assertEqual (
1639+ os .path .exists (
1640+ os .path .join (outcome .staging_dir , "proposed_CLAUDE.md" )
1641+ ),
1642+ expect_memory ,
1643+ )
1644+
1645+ def test_cycle_stages_only_documents_that_changed (self ):
1646+ # The staging contract is byte/text equality, not semantic or whitespace
1647+ # normalized comparison: an accepted cycle proposes a document only when it
1648+ # actually rewrote it. Covered for every shape an accepted result can take,
1649+ # so a symmetric regression on the skill side cannot hide behind the
1650+ # memory-only case.
1651+ skill = "# managed baseline\n rule\n "
1652+ memory = "# memory baseline\n preference\n "
1653+ new_skill = skill + "prefer the shortest reproduction\n "
1654+ new_memory = memory + "learned preference\n "
1655+ cases = (
1656+ ("neither_changed" , skill , memory , False , False ),
1657+ ("skill_only" , new_skill , memory , True , False ),
1658+ ("memory_only" , skill , new_memory , False , True ),
1659+ ("both_changed" , new_skill , new_memory , True , True ),
1660+ # Whitespace-only is a real change under a byte-equality contract, so it
1661+ # is a positive case. If this ever fails, the comparison has started
1662+ # normalizing and the documented contract has silently moved.
1663+ ("whitespace_only_skill" , skill + "\n " , memory , True , False ),
1664+ ("whitespace_only_memory" , skill , memory + " \n " , False , True ),
1665+ )
1666+ for name , candidate_skill , candidate_memory , expect_skill , expect_memory in cases :
1667+ with self .subTest (case = name ):
1668+ self ._assert_only_changed_documents_are_staged (
1669+ candidate_skill ,
1670+ candidate_memory ,
1671+ expect_skill ,
1672+ expect_memory ,
1673+ )
1674+
15671675 def test_managed_skill_change_during_consolidation_refuses_the_night (self ):
15681676 from skillopt_sleep .consolidate import ConsolidationResult
15691677 from skillopt_sleep .staging import StagingError , latest_staging
0 commit comments