@@ -168,6 +168,16 @@ class WorktreeContextInjector {
168168 */
169169 private const MEMORY_FILES = array ( 'MEMORY.md ' , 'USER.md ' , 'RULES.md ' );
170170
171+ /**
172+ * Filterable registry hook for worktree context projection targets.
173+ */
174+ public const PROJECTION_TARGETS_FILTER = 'datamachine_code_worktree_context_projection_targets ' ;
175+
176+ /**
177+ * Filterable registry hook for projection cleanup markers.
178+ */
179+ public const PROJECTION_CLEANUP_FILTER = 'datamachine_code_worktree_context_projection_cleanup ' ;
180+
171181 /**
172182 * Site-provided sections that should be visible before long memory snapshots.
173183 */
@@ -1085,46 +1095,24 @@ public static function inject( string $worktree_path, array $payload ): array|\W
10851095 );
10861096 }
10871097
1088- $ body = self :: render ( $ payload );
1089- $ written = array ();
1098+ $ written = array ( );
1099+ $ exclude_entries = array ();
10901100
1091- foreach ( self ::INJECTED_PATHS as $ relative ) {
1092- $ abs = rtrim ($ worktree_path , '/ ' ) . '/ ' . $ relative ;
1093- $ dir = dirname ($ abs );
1094- if ( ! is_dir ($ dir ) ) {
1095- // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
1096- if ( ! wp_mkdir_p ($ dir ) ) {
1097- return new \WP_Error (
1098- 'mkdir_failed ' ,
1099- sprintf ('Failed to create directory: %s ' , $ dir ),
1100- array ( 'status ' => 500 )
1101- );
1102- }
1103- }
1104- // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1105- $ bytes = file_put_contents ($ abs , $ body );
1106- if ( false === $ bytes ) {
1107- return new \WP_Error (
1108- 'write_failed ' ,
1109- sprintf ('Failed to write injected file: %s ' , $ abs ),
1110- array ( 'status ' => 500 )
1111- );
1101+ foreach ( self ::get_projection_targets ($ payload ) as $ target ) {
1102+ if ( ! is_array ($ target ) ) {
1103+ continue ;
11121104 }
1113- $ written [] = $ abs ;
1114- }
11151105
1116- $ agents_projection = self ::project_site_agents_md ($ worktree_path , $ payload );
1117- if ( is_wp_error ($ agents_projection ) ) {
1118- return $ agents_projection ;
1119- }
1120- $ written = array_merge ($ written , $ agents_projection );
1106+ $ result = self ::project_context_target ($ worktree_path , $ payload , $ target );
1107+ if ( is_wp_error ($ result ) ) {
1108+ return $ result ;
1109+ }
11211110
1122- $ exclude_entries = self ::INJECTED_PATHS ;
1123- if ( ! empty ($ agents_projection ) ) {
1124- $ exclude_entries [] = self ::PROJECTED_AGENTS_PATH ;
1125- $ exclude_entries [] = self ::PROJECTED_AGENTS_MARKER_PATH ;
1126- $ exclude_entries [] = self ::PROJECTED_OPENCODE_CONFIG_PATH ;
1127- $ exclude_entries [] = self ::PROJECTED_OPENCODE_CONFIG_MARKER_PATH ;
1111+ $ target_written = $ result ['written ' ] ?? array ();
1112+ $ written = array_merge ($ written , $ target_written );
1113+ if ( ! empty ($ result ['exclude ' ]) ) {
1114+ $ exclude_entries = array_merge ($ exclude_entries , $ result ['exclude ' ]);
1115+ }
11281116 }
11291117
11301118 $ exclude_path = self ::append_exclude_entries ($ worktree_path , $ exclude_entries );
@@ -1136,6 +1124,162 @@ public static function inject( string $worktree_path, array $payload ): array|\W
11361124 );
11371125 }
11381126
1127+ /**
1128+ * Return registered worktree context projection targets.
1129+ *
1130+ * Registry entry shape:
1131+ * - `path`: relative file path for rendered payload writes.
1132+ * - `renderer`: optional callable receiving payload and returning string.
1133+ * - `projector`: optional callable receiving worktree path and payload.
1134+ * - `exclude`: bool or relative path list to add to per-checkout exclude.
1135+ * - `exclude_paths`: relative path list added when a projector writes files.
1136+ *
1137+ * @param array $payload Payload from {@see self::build_payload()}.
1138+ * @return array<string,array<string,mixed>> Projection target registry.
1139+ */
1140+ public static function get_projection_targets ( array $ payload = array () ): array {
1141+ $ targets = array (
1142+ 'claude_local_context ' => array (
1143+ 'path ' => self ::INJECTED_PATHS [0 ],
1144+ 'renderer ' => array ( self ::class, 'render ' ),
1145+ 'exclude ' => true ,
1146+ ),
1147+ 'site_agents_md ' => array (
1148+ 'projector ' => array ( self ::class, 'project_site_agents_md ' ),
1149+ 'exclude_paths ' => array (
1150+ self ::PROJECTED_AGENTS_PATH ,
1151+ self ::PROJECTED_AGENTS_MARKER_PATH ,
1152+ self ::PROJECTED_OPENCODE_CONFIG_PATH ,
1153+ self ::PROJECTED_OPENCODE_CONFIG_MARKER_PATH ,
1154+ ),
1155+ ),
1156+ );
1157+
1158+ if ( function_exists ('apply_filters ' ) ) {
1159+ $ targets = apply_filters (self ::PROJECTION_TARGETS_FILTER , $ targets , $ payload );
1160+ }
1161+
1162+ return is_array ($ targets ) ? $ targets : array ();
1163+ }
1164+
1165+ /**
1166+ * Return registered cleanup handlers and marker paths.
1167+ *
1168+ * @return array<string,array<string,mixed>> Projection cleanup registry.
1169+ */
1170+ public static function get_projection_cleanup_registry (): array {
1171+ $ cleanup = array (
1172+ 'site_agents_md ' => array (
1173+ 'cleanup ' => array ( self ::class, 'cleanup_site_agents_projection ' ),
1174+ ),
1175+ 'opencode_config ' => array (
1176+ 'cleanup ' => array ( self ::class, 'cleanup_opencode_projection ' ),
1177+ ),
1178+ 'claude_local_context ' => array (
1179+ 'paths ' => self ::INJECTED_PATHS ,
1180+ ),
1181+ 'legacy_opencode ' => array (
1182+ 'paths ' => self ::LEGACY_INJECTED_PATHS ,
1183+ ),
1184+ );
1185+
1186+ if ( function_exists ('apply_filters ' ) ) {
1187+ $ cleanup = apply_filters (self ::PROJECTION_CLEANUP_FILTER , $ cleanup );
1188+ }
1189+
1190+ return is_array ($ cleanup ) ? $ cleanup : array ();
1191+ }
1192+
1193+ /**
1194+ * Apply one projection target from the registry.
1195+ *
1196+ * @param string $worktree_path Absolute path to the worktree directory.
1197+ * @param array $payload Payload from {@see self::build_payload()}.
1198+ * @param array $target Projection target configuration.
1199+ * @return array{written:string[],exclude:string[]}|\WP_Error Projection result.
1200+ */
1201+ private static function project_context_target ( string $ worktree_path , array $ payload , array $ target ): array |\WP_Error {
1202+ $ written = array ();
1203+ $ exclude = array ();
1204+
1205+ if ( isset ($ target ['path ' ]) && is_string ($ target ['path ' ]) && '' !== trim ($ target ['path ' ]) ) {
1206+ $ renderer = $ target ['renderer ' ] ?? array ( self ::class, 'render ' );
1207+ if ( ! is_callable ($ renderer ) ) {
1208+ return new \WP_Error ('context_projection_renderer_invalid ' , 'Context projection renderer is not callable. ' , array ( 'status ' => 500 ));
1209+ }
1210+
1211+ $ body = call_user_func ($ renderer , $ payload , $ worktree_path , $ target );
1212+ if ( ! is_string ($ body ) ) {
1213+ return new \WP_Error ('context_projection_renderer_invalid ' , 'Context projection renderer must return a string. ' , array ( 'status ' => 500 ));
1214+ }
1215+
1216+ $ result = self ::write_projection_file ($ worktree_path , $ target ['path ' ], $ body );
1217+ if ( is_wp_error ($ result ) ) {
1218+ return $ result ;
1219+ }
1220+ $ written [] = $ result ;
1221+
1222+ if ( true === ( $ target ['exclude ' ] ?? false ) ) {
1223+ $ exclude [] = $ target ['path ' ];
1224+ }
1225+ }
1226+
1227+ if ( isset ($ target ['projector ' ]) ) {
1228+ if ( ! is_callable ($ target ['projector ' ]) ) {
1229+ return new \WP_Error ('context_projection_projector_invalid ' , 'Context projection projector is not callable. ' , array ( 'status ' => 500 ));
1230+ }
1231+
1232+ $ result = call_user_func ($ target ['projector ' ], $ worktree_path , $ payload , $ target );
1233+ if ( is_wp_error ($ result ) ) {
1234+ return $ result ;
1235+ }
1236+ if ( ! is_array ($ result ) ) {
1237+ return new \WP_Error ('context_projection_projector_invalid ' , 'Context projection projector must return an array of written paths. ' , array ( 'status ' => 500 ));
1238+ }
1239+ $ written = array_merge ($ written , array_values ($ result ));
1240+
1241+ if ( ! empty ($ result ) && ! empty ($ target ['exclude_paths ' ]) && is_array ($ target ['exclude_paths ' ]) ) {
1242+ $ exclude = array_merge ($ exclude , array_values ($ target ['exclude_paths ' ]));
1243+ }
1244+ }
1245+
1246+ if ( isset ($ target ['exclude ' ]) && is_array ($ target ['exclude ' ]) ) {
1247+ $ exclude = array_merge ($ exclude , array_values ($ target ['exclude ' ]));
1248+ }
1249+
1250+ return array (
1251+ 'written ' => $ written ,
1252+ 'exclude ' => $ exclude ,
1253+ );
1254+ }
1255+
1256+ /**
1257+ * Write a generated projection file under a worktree.
1258+ *
1259+ * @param string $worktree_path Absolute path to the worktree directory.
1260+ * @param string $relative Relative projection path.
1261+ * @param string $body Projection content.
1262+ * @return string|\WP_Error Absolute written path or error.
1263+ */
1264+ private static function write_projection_file ( string $ worktree_path , string $ relative , string $ body ): string |\WP_Error {
1265+ $ abs = rtrim ($ worktree_path , '/ ' ) . '/ ' . ltrim ($ relative , '/ ' );
1266+ $ dir = dirname ($ abs );
1267+ if ( ! is_dir ($ dir ) ) {
1268+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
1269+ if ( ! wp_mkdir_p ($ dir ) ) {
1270+ return new \WP_Error ('mkdir_failed ' , sprintf ('Failed to create directory: %s ' , $ dir ), array ( 'status ' => 500 ));
1271+ }
1272+ }
1273+
1274+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1275+ $ bytes = file_put_contents ($ abs , $ body );
1276+ if ( false === $ bytes ) {
1277+ return new \WP_Error ('write_failed ' , sprintf ('Failed to write injected file: %s ' , $ abs ), array ( 'status ' => 500 ));
1278+ }
1279+
1280+ return $ abs ;
1281+ }
1282+
11391283 /**
11401284 * Project the originating site's composed AGENTS.md into OpenCode discovery.
11411285 *
@@ -1327,6 +1471,38 @@ private static function project_site_agents_md_via_opencode_config( string $work
13271471 * @return array{success: bool, removed: string[]}
13281472 */
13291473 public static function uninject ( string $ worktree_path ): array {
1474+ $ removed = array ();
1475+
1476+ foreach ( self ::get_projection_cleanup_registry () as $ entry ) {
1477+ if ( ! is_array ($ entry ) ) {
1478+ continue ;
1479+ }
1480+
1481+ if ( isset ($ entry ['cleanup ' ]) && is_callable ($ entry ['cleanup ' ]) ) {
1482+ $ result = call_user_func ($ entry ['cleanup ' ], $ worktree_path , $ entry );
1483+ if ( is_array ($ result ) ) {
1484+ $ removed = array_merge ($ removed , array_values ($ result ));
1485+ }
1486+ }
1487+
1488+ if ( ! empty ($ entry ['paths ' ]) && is_array ($ entry ['paths ' ]) ) {
1489+ $ removed = array_merge ($ removed , self ::remove_projection_paths ($ worktree_path , $ entry ['paths ' ]));
1490+ }
1491+ }
1492+
1493+ return array (
1494+ 'success ' => true ,
1495+ 'removed ' => $ removed ,
1496+ );
1497+ }
1498+
1499+ /**
1500+ * Remove DMC-owned AGENTS.md projections from a worktree.
1501+ *
1502+ * @param string $worktree_path Worktree directory.
1503+ * @return string[] Removed absolute paths.
1504+ */
1505+ private static function cleanup_site_agents_projection ( string $ worktree_path ): array {
13301506 $ removed = array ();
13311507 $ projected_agents = rtrim ($ worktree_path , '/ ' ) . '/ ' . self ::PROJECTED_AGENTS_PATH ;
13321508 $ projection_marker = rtrim ($ worktree_path , '/ ' ) . '/ ' . self ::PROJECTED_AGENTS_MARKER_PATH ;
@@ -1358,6 +1534,17 @@ public static function uninject( string $worktree_path ): array {
13581534 $ removed [] = $ projection_marker ;
13591535 }
13601536
1537+ return $ removed ;
1538+ }
1539+
1540+ /**
1541+ * Restore or remove DMC-owned OpenCode config projections from a worktree.
1542+ *
1543+ * @param string $worktree_path Worktree directory.
1544+ * @return string[] Removed absolute paths.
1545+ */
1546+ private static function cleanup_opencode_projection ( string $ worktree_path ): array {
1547+ $ removed = array ();
13611548 $ opencode_config = rtrim ($ worktree_path , '/ ' ) . '/ ' . self ::PROJECTED_OPENCODE_CONFIG_PATH ;
13621549 $ opencode_marker = rtrim ($ worktree_path , '/ ' ) . '/ ' . self ::PROJECTED_OPENCODE_CONFIG_MARKER_PATH ;
13631550 if ( is_file ($ opencode_marker ) ) {
@@ -1376,7 +1563,19 @@ public static function uninject( string $worktree_path ): array {
13761563 $ removed [] = $ opencode_marker ;
13771564 }
13781565
1379- foreach ( array_merge (self ::INJECTED_PATHS , self ::LEGACY_INJECTED_PATHS ) as $ relative ) {
1566+ return $ removed ;
1567+ }
1568+
1569+ /**
1570+ * Remove relative projection paths from a worktree.
1571+ *
1572+ * @param string $worktree_path Worktree directory.
1573+ * @param string[] $paths Relative paths to remove.
1574+ * @return string[] Removed absolute paths.
1575+ */
1576+ private static function remove_projection_paths ( string $ worktree_path , array $ paths ): array {
1577+ $ removed = array ();
1578+ foreach ( $ paths as $ relative ) {
13801579 $ abs = rtrim ($ worktree_path , '/ ' ) . '/ ' . $ relative ;
13811580 if ( is_file ($ abs ) ) {
13821581 // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes DMC-injected local-only context files from a worktree.
@@ -1385,10 +1584,7 @@ public static function uninject( string $worktree_path ): array {
13851584 }
13861585 }
13871586
1388- return array (
1389- 'success ' => true ,
1390- 'removed ' => $ removed ,
1391- );
1587+ return $ removed ;
13921588 }
13931589
13941590 /**
0 commit comments