@@ -26,6 +26,7 @@ pub enum RenderOp {
2626 JsonUpsert {
2727 destination : String ,
2828 content : String ,
29+ arrays : ArrayMerge ,
2930 } ,
3031 EnsureLine {
3132 destination : String ,
@@ -36,6 +37,17 @@ pub enum RenderOp {
3637 } ,
3738}
3839
40+ /// How a json-upsert treats an array both sides declare. `Replace` is the default and the
41+ /// original contract; `union` appends patch elements the target lacks (exact-equality dedupe), so
42+ /// registrations can join arrays other owners also write — user-declared entries survive and
43+ /// re-materialization is idempotent.
44+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Default ) ]
45+ pub enum ArrayMerge {
46+ #[ default]
47+ Replace ,
48+ Union ,
49+ }
50+
3951#[ derive( Debug , Clone , PartialEq , Eq , Default ) ]
4052pub struct RenderPlan {
4153 pub ops : Vec < RenderOp > ,
@@ -68,6 +80,7 @@ impl RenderOp {
6880 | Self :: JsonUpsert {
6981 destination,
7082 content,
83+ ..
7184 } => {
7285 references_variable ( destination, variable) || references_variable ( content, variable)
7386 }
@@ -162,9 +175,22 @@ fn parse_render_node(node: &KdlNode, agent: &str) -> Result<RenderPlan> {
162175 serde_json:: from_str :: < serde_json:: Value > ( & content) . with_context ( || {
163176 format ! ( "agent '{agent}': json-upsert content is not valid JSON" )
164177 } ) ?;
178+ let arrays = match directive
179+ . entries ( )
180+ . iter ( )
181+ . find ( |entry| entry. name ( ) . is_some_and ( |name| name. value ( ) == "arrays" ) )
182+ . and_then ( |entry| entry. value ( ) . as_string ( ) )
183+ {
184+ None | Some ( "replace" ) => ArrayMerge :: Replace ,
185+ Some ( "union" ) => ArrayMerge :: Union ,
186+ Some ( other) => anyhow:: bail!(
187+ "agent '{agent}': json-upsert arrays=\" {other}\" (expected replace|union)"
188+ ) ,
189+ } ;
165190 plan. ops . push ( RenderOp :: JsonUpsert {
166191 destination : destination. clone ( ) ,
167192 content,
193+ arrays,
168194 } ) ;
169195 }
170196 "ensure-line" => {
@@ -267,6 +293,7 @@ fn resolve_driver_render_executable(plan: &mut RenderPlan, agent: &str) -> Resul
267293 let RenderOp :: JsonUpsert {
268294 destination,
269295 content,
296+ ..
270297 } = operation
271298 else {
272299 continue ;
@@ -312,6 +339,7 @@ fn effective_plan(root: &Path, spec: &AgentSpec, this_host: &str) -> Result<Rend
312339 plan. ops . push ( RenderOp :: JsonUpsert {
313340 destination : ".mcp.json" . into ( ) ,
314341 content,
342+ arrays : ArrayMerge :: Replace ,
315343 } ) ;
316344 }
317345 Ok ( plan)
@@ -466,18 +494,27 @@ fn ensure_line(path: &Path, line: &str) -> Result<bool> {
466494 Ok ( true )
467495}
468496
469- fn deep_merge ( target : & mut serde_json:: Value , patch : serde_json:: Value ) {
497+ fn deep_merge ( target : & mut serde_json:: Value , patch : serde_json:: Value , arrays : ArrayMerge ) {
470498 match ( target, patch) {
471499 ( serde_json:: Value :: Object ( target) , serde_json:: Value :: Object ( patch) ) => {
472500 for ( key, value) in patch {
473501 match target. get_mut ( & key) {
474- Some ( existing) => deep_merge ( existing, value) ,
502+ Some ( existing) => deep_merge ( existing, value, arrays ) ,
475503 None => {
476504 target. insert ( key, value) ;
477505 }
478506 }
479507 }
480508 }
509+ ( serde_json:: Value :: Array ( target) , serde_json:: Value :: Array ( patch) )
510+ if arrays == ArrayMerge :: Union =>
511+ {
512+ for element in patch {
513+ if !target. contains ( & element) {
514+ target. push ( element) ;
515+ }
516+ }
517+ }
481518 ( target, patch) => * target = patch,
482519 }
483520}
@@ -563,7 +600,7 @@ enum PreparedOp {
563600#[ derive( Debug , Clone , PartialEq ) ]
564601enum RenderClaim {
565602 Replace ( Vec < u8 > ) ,
566- JsonUpsert ( serde_json:: Value ) ,
603+ JsonUpsert ( serde_json:: Value , ArrayMerge ) ,
567604 EnsureLine ( String ) ,
568605}
569606
@@ -627,6 +664,7 @@ fn claims_for_agent(
627664 RenderOp :: JsonUpsert {
628665 destination : raw_destination,
629666 content,
667+ arrays,
630668 } => {
631669 let patch = serde_json:: from_str ( & expand ( & content, & env) ) . with_context ( || {
632670 format ! (
@@ -636,7 +674,7 @@ fn claims_for_agent(
636674 } ) ?;
637675 (
638676 destination ( & workspace, & raw_destination, & env) ?,
639- RenderClaim :: JsonUpsert ( patch) ,
677+ RenderClaim :: JsonUpsert ( patch, arrays ) ,
640678 )
641679 }
642680 RenderOp :: EnsureLine {
@@ -784,6 +822,7 @@ pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Resu
784822 RenderOp :: JsonUpsert {
785823 destination : raw_destination,
786824 content,
825+ arrays,
787826 } => {
788827 let destination = destination ( & workspace, & raw_destination, & env) ?;
789828 let current = virtual_files
@@ -804,7 +843,7 @@ pub fn materialize_agent(root: &Path, spec: &AgentSpec, this_host: &str) -> Resu
804843 spec. identity
805844 )
806845 } ) ?;
807- deep_merge ( & mut target, patch) ;
846+ deep_merge ( & mut target, patch, arrays ) ;
808847 let mut bytes = serde_json:: to_vec_pretty ( & target) ?;
809848 bytes. push ( b'\n' ) ;
810849 let note = format ! ( "{}: upserted {}" , spec. identity, raw_destination) ;
@@ -1056,6 +1095,7 @@ mod tests {
10561095 "nested" : { "right" : 2 , "replace" : "new" } ,
10571096 "array" : [ 2 ]
10581097 } ) ,
1098+ ArrayMerge :: Replace ,
10591099 ) ;
10601100 assert_eq ! (
10611101 target,
@@ -1066,4 +1106,27 @@ mod tests {
10661106 } )
10671107 ) ;
10681108 }
1109+
1110+ /// Union mode joins arrays idempotently: foreign entries survive and repeating the same
1111+ /// patch adds nothing — the contract the generated hook registration relies on.
1112+ #[ test]
1113+ fn deep_merge_union_preserves_foreign_array_entries_and_is_idempotent ( ) {
1114+ let mut target = serde_json:: json!( {
1115+ "hooks" : { "Stop" : [ { "hooks" : [ { "type" : "command" , "command" : "user-audit.sh" } ] } ] }
1116+ } ) ;
1117+ let ours = serde_json:: json!( {
1118+ "hooks" : { "Stop" : [ { "hooks" : [ { "type" : "command" , "command" : "$ST_HOOKS/claude-observe.sh Stop" } ] } ] }
1119+ } ) ;
1120+ deep_merge ( & mut target, ours. clone ( ) , ArrayMerge :: Union ) ;
1121+ deep_merge ( & mut target, ours, ArrayMerge :: Union ) ;
1122+ assert_eq ! (
1123+ target,
1124+ serde_json:: json!( {
1125+ "hooks" : { "Stop" : [
1126+ { "hooks" : [ { "type" : "command" , "command" : "user-audit.sh" } ] } ,
1127+ { "hooks" : [ { "type" : "command" , "command" : "$ST_HOOKS/claude-observe.sh Stop" } ] }
1128+ ] }
1129+ } )
1130+ ) ;
1131+ }
10691132}
0 commit comments