*/ private const EDITOR_TYPES = ['post', 'page']; /** * Agentic Bridge admin workspace URL for actions that must not run inside the post editor form. * * @param array $args */ public static function workspace_url(string $view, int $post_id = 0, array $args = []): string { $query = array_merge( ['thedotdev_agentic_bridge_view' => sanitize_key($view)], $args ); if ($post_id > 0) { $query['thedotdev_agentic_bridge_metadata_id'] = $post_id; } return TheDotDevAgenticBridge_Admin::admin_page_url($query); } public static function readiness_scan_workspace_url(): string { return self::workspace_url('findings'); } public static function content_workspace_url(int $post_id): string { return self::workspace_url('content', $post_id); } /** * @param array $attributes */ public static function render_workspace_link(string $label, string $url, array $attributes = []): void { $class = isset($attributes['class']) ? (string) $attributes['class'] : 'button button-secondary'; unset($attributes['class']); $attr_pairs = ['class' => $class, 'href' => $url]; foreach ($attributes as $name => $value) { $attr_pairs[(string) $name] = (string) $value; } $html = ' $value) { $html .= ' ' . $name . '="' . esc_attr($value) . '"'; } $html .= '>' . esc_html($label) . ''; echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Built with esc_attr/esc_html above. } public static function register(): void { add_action('init', [self::class, 'register_post_meta']); add_action('add_meta_boxes', [self::class, 'register_meta_box']); add_action('save_post', [self::class, 'save_post'], 10, 2); } public static function register_post_meta(): void { if (!function_exists('register_post_meta')) { return; } foreach (self::EDITOR_TYPES as $post_type) { register_post_meta($post_type, self::META_CONFIG_JSON, [ 'single' => true, 'type' => 'string', 'show_in_rest' => true, 'auth_callback' => [self::class, 'auth_meta_edit'], 'sanitize_callback' => [self::class, 'sanitize_meta_config_json'], ]); register_post_meta($post_type, self::META_SCORE, [ 'single' => true, 'type' => 'integer', 'show_in_rest' => true, 'auth_callback' => [self::class, 'auth_meta_edit'], 'sanitize_callback' => 'absint', ]); register_post_meta($post_type, self::META_UPDATED_AT, [ 'single' => true, 'type' => 'string', 'show_in_rest' => true, 'auth_callback' => [self::class, 'auth_meta_edit'], 'sanitize_callback' => 'sanitize_text_field', ]); } } public static function register_meta_box(): void { foreach (self::EDITOR_TYPES as $post_type) { add_meta_box( self::META_BOX_ID, __('Agent Ready', 'agentic-bridge'), [self::class, 'render_meta_box'], $post_type, 'normal', 'default' ); add_meta_box( self::CONTROLLED_ANSWER_META_BOX_ID, __('Controlled answerable questions', 'agentic-bridge'), [self::class, 'render_controlled_answer_meta_box'], $post_type, 'normal', 'default' ); add_meta_box( self::CONTENT_READINESS_FIX_META_BOX_ID, __('Local readiness changes', 'agentic-bridge'), [self::class, 'render_content_readiness_fix_meta_box'], $post_type, 'normal', 'default' ); } } /** * @param WP_Post $post */ public static function render_meta_box($post): void { if (!$post instanceof WP_Post) { return; } if (!in_array($post->post_type, self::EDITOR_TYPES, true)) { return; } if (!current_user_can('edit_post', $post->ID)) { return; } $manageable = TheDotDevAgenticBridge_Content_Policy::is_manageable_preview_post((int) $post->ID); $configurable = $manageable && TheDotDevAgenticBridge_Content_Metadata::is_configurable_content($post); $score_result = TheDotDevAgenticBridge_Content_Agent_Ready_Score::score($post); $policy = TheDotDevAgenticBridge_Content_Policy::evaluate_post($post); $store = TheDotDevAgenticBridge_Content_Metadata::normalized_store(); $stored = $store['by_content_id'][(string) $post->ID] ?? null; $form_config = is_array($stored) ? TheDotDevAgenticBridge_Content_Metadata::effective_config_for_post($post) : array_merge( TheDotDevAgenticBridge_Content_Metadata::effective_config_for_post($post), [ 'inclusionMode' => TheDotDevAgenticBridge_Content_Metadata::INCLUSION_INHERIT, 'inheritDefaults' => true, ] ); $meta_config = self::config_from_meta_json((string) get_post_meta($post->ID, self::META_CONFIG_JSON, true)); if ($meta_config !== null) { $form_config = array_merge($form_config, $meta_config); } // Template locals for views/partials/agent-ready-editor.php (primary $thedotdev_agentic_bridge_* names). $thedotdev_agentic_bridge_editor_post = $post; $thedotdev_agentic_bridge_editor_manageable = $manageable; $thedotdev_agentic_bridge_editor_configurable = $configurable; $thedotdev_agentic_bridge_editor_score = $score_result; $thedotdev_agentic_bridge_editor_policy_reason = (string) ($policy['reason'] ?? ''); $thedotdev_agentic_bridge_metadata_form_config = $form_config; $thedotdev_agentic_bridge_metadata_show_inclusion = true; $thedotdev_agentic_bridge_metadata_id_prefix = 'tdab-editor-' . (string) $post->ID . '-'; $thedotdev_agentic_bridge_editor_template = TheDotDevAgenticBridge_V2_Presentation::is_enabled() ? 'views/v2/partials/editor-content-panel.php' : 'views/partials/agent-ready-editor.php'; include THEDOTDEV_AGENTIC_BRIDGE_PLUGIN_DIR . $thedotdev_agentic_bridge_editor_template; } /** * Native editor surface for the same controlled-answer lifecycle used by * the Content workspace. It deliberately owns no metadata or workflow. * * @param WP_Post $post */ public static function render_controlled_answer_meta_box($post): void { if (!$post instanceof WP_Post || !in_array($post->post_type, self::EDITOR_TYPES, true)) { return; } if (!current_user_can('edit_post', $post->ID)) { return; } TheDotDevAgenticBridge_Controlled_Answerable_Questions::render_editor_surface($post); } /** @param WP_Post $post */ public static function render_content_readiness_fix_meta_box($post): void { if (!$post instanceof WP_Post || !in_array($post->post_type, self::EDITOR_TYPES, true) || !current_user_can('edit_post', $post->ID)) { return; } TheDotDevAgenticBridge_Content_Readiness_Fix_Lifecycle::render_editor($post); } /** * @param int $post_id * @param WP_Post|null $post */ public static function save_post($post_id, $post = null): void { $post_id = (int) $post_id; if ($post_id <= 0) { return; } if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { return; } if (function_exists('wp_is_post_revision') && wp_is_post_revision($post_id)) { return; } if (!current_user_can('edit_post', $post_id)) { return; } if (!$post instanceof WP_Post) { $post = get_post($post_id); } if (!$post instanceof WP_Post || !in_array($post->post_type, self::EDITOR_TYPES, true)) { return; } if (!TheDotDevAgenticBridge_Content_Policy::is_manageable_preview_post($post_id) || !TheDotDevAgenticBridge_Content_Metadata::is_configurable_content($post) ) { // Fail-closed: ineligible content never mutates Agent Ready metadata from the editor. return; } $input = null; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Presence gate before nonce verify below. if (isset($_POST[self::NONCE_NAME])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Verified immediately below. $raw_nonce = wp_unslash($_POST[self::NONCE_NAME]); $nonce = is_string($raw_nonce) ? sanitize_text_field($raw_nonce) : ''; if ($nonce === '' || !wp_verify_nonce($nonce, self::NONCE_ACTION)) { return; } $input = self::metadata_input_from_post(); } else { $meta_config = self::config_from_meta_json((string) get_post_meta($post_id, self::META_CONFIG_JSON, true)); if (is_array($meta_config)) { $input = $meta_config; } } if (!is_array($input)) { self::sync_score_meta($post); return; } $save_result = TheDotDevAgenticBridge_Content_Metadata::save_content_config($post_id, $input); if (empty($save_result['ok'])) { return; } self::sync_config_meta($post); self::sync_score_meta($post); } /** @return array */ public static function metadata_input_from_post(): array { return [ 'inclusionMode' => self::post_has_key('inclusion_mode') ? self::raw_post_input('inclusion_mode') : TheDotDevAgenticBridge_Content_Metadata::INCLUSION_INHERIT, 'inheritDefaults' => self::post_is_set('inherit_defaults'), 'intent' => self::post_has_key('intent') ? self::raw_post_input('intent') : 'general_information', 'allowedActions' => self::post_is_set('allowed_actions') ? self::raw_post_input('allowed_actions') : [], 'blockedClaims' => self::post_is_set('blocked_claims') ? self::raw_post_input('blocked_claims') : [], 'customBlockedClaims' => self::post_is_set('custom_blocked_claims') ? self::raw_post_input('custom_blocked_claims') : [], 'agentSummary' => self::post_has_key('agent_summary') ? self::raw_post_input('agent_summary') : '', 'relationshipLabel' => self::post_has_key('relationship_label') ? self::raw_post_input('relationship_label') : '', 'relationshipType' => self::post_has_key('relationship_type') ? self::raw_post_input('relationship_type') : '', ]; } private static function post_has_key(string $key): bool { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Caller verified nonce. return array_key_exists($key, $_POST); } private static function post_is_set(string $key): bool { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Caller verified nonce. return isset($_POST[$key]); } private static function raw_post_input(string $key): mixed { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Caller verified nonce; domain validation in TheDotDevAgenticBridge_Content_Metadata. return wp_unslash($_POST[$key] ?? null); } public static function auth_meta_edit(): bool { return current_user_can('edit_posts'); } public static function sanitize_meta_config_json(mixed $value): string { if (!is_string($value)) { return ''; } $config = self::config_from_meta_json($value); if ($config === null) { return ''; } $json = wp_json_encode($config); return is_string($json) ? $json : ''; } /** @return array|null */ private static function config_from_meta_json(string $raw): ?array { if (trim($raw) === '') { return null; } $decoded = json_decode($raw, true); if (!is_array($decoded)) { return null; } $intent = sanitize_key((string) ($decoded['intent'] ?? 'general_information')); if (!in_array($intent, TheDotDevAgenticBridge_Content_Metadata::INTENTS, true)) { $intent = 'general_information'; } $inclusion_mode = sanitize_key((string) ($decoded['inclusionMode'] ?? TheDotDevAgenticBridge_Content_Metadata::INCLUSION_INHERIT)); if (!in_array($inclusion_mode, TheDotDevAgenticBridge_Content_Metadata::INCLUSION_MODES, true)) { $inclusion_mode = TheDotDevAgenticBridge_Content_Metadata::INCLUSION_INHERIT; } $allowed_actions = []; if (is_array($decoded['allowedActions'] ?? null)) { foreach ($decoded['allowedActions'] as $action) { $key = sanitize_key((string) $action); if (in_array($key, TheDotDevAgenticBridge_Content_Metadata::ALLOWED_ACTION_IDS, true)) { $allowed_actions[] = $key; } } } if ($allowed_actions === []) { $allowed_actions = ['read_content']; } $blocked_claims = []; if (is_array($decoded['blockedClaims'] ?? null)) { foreach ($decoded['blockedClaims'] as $claim) { $key = sanitize_key((string) $claim); if (in_array($key, TheDotDevAgenticBridge_Content_Metadata::BLOCKED_CLAIM_IDS, true)) { $blocked_claims[] = $key; } } } $custom_blocked = []; if (is_array($decoded['customBlockedClaims'] ?? null)) { foreach ($decoded['customBlockedClaims'] as $claim) { $claim_text = trim(wp_strip_all_tags((string) $claim)); if ($claim_text !== '') { $custom_blocked[] = substr($claim_text, 0, TheDotDevAgenticBridge_Content_Metadata::MAX_CUSTOM_CLAIM_CHARS); } if (count($custom_blocked) >= TheDotDevAgenticBridge_Content_Metadata::MAX_CUSTOM_BLOCKED) { break; } } } $relationship_type = sanitize_key((string) ($decoded['relationshipType'] ?? '')); if (!in_array($relationship_type, TheDotDevAgenticBridge_Content_Metadata::RELATIONSHIP_TYPES, true)) { $relationship_type = ''; } return [ 'inclusionMode' => $inclusion_mode, 'inheritDefaults' => !empty($decoded['inheritDefaults']), 'intent' => $intent, 'allowedActions' => array_values(array_unique($allowed_actions)), 'blockedClaims' => array_values(array_unique($blocked_claims)), 'customBlockedClaims' => $custom_blocked, 'agentSummary' => substr(trim(wp_strip_all_tags((string) ($decoded['agentSummary'] ?? ''))), 0, TheDotDevAgenticBridge_Content_Metadata::MAX_SUMMARY_CHARS), 'relationshipLabel' => substr(trim(wp_strip_all_tags((string) ($decoded['relationshipLabel'] ?? ''))), 0, TheDotDevAgenticBridge_Content_Metadata::MAX_RELATIONSHIP_CHARS), 'relationshipType' => $relationship_type, ]; } private static function sync_config_meta(WP_Post $post): void { if (!function_exists('update_post_meta')) { return; } $config = TheDotDevAgenticBridge_Content_Metadata::effective_config_for_post($post); $payload = [ 'inclusionMode' => (string) ($config['inclusionMode'] ?? TheDotDevAgenticBridge_Content_Metadata::INCLUSION_INHERIT), 'inheritDefaults' => !empty($config['inheritDefaults']), 'intent' => (string) ($config['intent'] ?? 'general_information'), 'allowedActions' => is_array($config['allowedActions'] ?? null) ? array_values($config['allowedActions']) : ['read_content'], 'blockedClaims' => is_array($config['blockedClaims'] ?? null) ? array_values($config['blockedClaims']) : [], 'customBlockedClaims' => is_array($config['customBlockedClaims'] ?? null) ? array_values($config['customBlockedClaims']) : [], 'agentSummary' => (string) ($config['agentSummary'] ?? ''), 'relationshipLabel' => (string) ($config['relationshipLabel'] ?? ''), 'relationshipType' => (string) ($config['relationshipType'] ?? ''), ]; update_post_meta($post->ID, self::META_CONFIG_JSON, wp_json_encode($payload)); update_post_meta($post->ID, self::META_UPDATED_AT, gmdate('c')); } private static function sync_score_meta(WP_Post $post): void { if (!function_exists('update_post_meta')) { return; } $score = TheDotDevAgenticBridge_Content_Agent_Ready_Score::score($post); update_post_meta($post->ID, self::META_SCORE, (int) ($score['score'] ?? 0)); } }