Spaces:
Sleeping
Sleeping
| defined( 'ABSPATH' ) || exit; | |
| /** | |
| * Sleeper Agents admin submenu — list / create / archive actors. | |
| * | |
| * Single page with view modes: | |
| * ?page=sa-orchestration-actors → list (default) | |
| * ?page=sa-orchestration-actors&view=create → create form | |
| * ?page=sa-orchestration-actors&view=detail&id=X → detail | |
| * | |
| * POST actions (admin-post-style, handled via admin_init when on this page): | |
| * sa_actor_action=create → create new actor; redirect to list | |
| * sa_actor_action=archive&id=X → archive actor; redirect to list | |
| * sa_actor_action=unarchive&id=X → unarchive actor; redirect to list | |
| * | |
| * Per the locked doctrine: actors created here are non-binding identity | |
| * records. They cannot progress Boards, accept gates, or write to | |
| * Asterion. This admin surface is the deliberate human-arbitrated path | |
| * for instantiating those identities. | |
| */ | |
| class SA_Orch_Actor_Admin { | |
| const SUBMENU_SLUG = 'sa-orchestration-actors'; | |
| public static function register_menu() { | |
| add_submenu_page( | |
| SA_Orch_Admin::PARENT_SLUG, | |
| 'Sleeper Agents', | |
| 'Sleeper Agents', | |
| SA_Orch_Admin::CAP, | |
| self::SUBMENU_SLUG, | |
| [ __CLASS__, 'render_page' ] | |
| ); | |
| } | |
| /** Catch POST actions on the submenu page before rendering. */ | |
| public static function maybe_handle_action() { | |
| if ( ! is_admin() ) return; | |
| if ( ! current_user_can( SA_Orch_Admin::CAP ) ) return; | |
| $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; | |
| if ( $page !== self::SUBMENU_SLUG ) return; | |
| $action = isset( $_POST['sa_actor_action'] ) ? sanitize_text_field( wp_unslash( $_POST['sa_actor_action'] ) ) : ''; | |
| if ( $action === '' ) return; | |
| check_admin_referer( 'sa_actor_form', 'sa_actor_nonce' ); | |
| if ( $action === 'create' ) { | |
| $data = [ | |
| 'name' => isset( $_POST['actor_name'] ) ? sanitize_text_field( wp_unslash( $_POST['actor_name'] ) ) : '', | |
| 'type' => isset( $_POST['actor_type'] ) ? sanitize_text_field( wp_unslash( $_POST['actor_type'] ) ) : '', | |
| 'persona' => isset( $_POST['actor_persona'] ) ? sanitize_textarea_field( wp_unslash( $_POST['actor_persona'] ) ) : '', | |
| 'scope' => isset( $_POST['actor_scope'] ) ? sanitize_textarea_field( wp_unslash( $_POST['actor_scope'] ) ) : '', | |
| ]; | |
| $result = SA_Orch_Actor_Registry::create_actor( $data ); | |
| if ( is_wp_error( $result ) ) { | |
| $msg = 'error&msg=' . rawurlencode( $result->get_error_message() ); | |
| } else { | |
| $msg = 'created&id=' . (int) $result; | |
| } | |
| wp_safe_redirect( admin_url( 'admin.php?page=' . self::SUBMENU_SLUG . '¬ice=' . $msg ) ); | |
| exit; | |
| } | |
| if ( $action === 'archive' || $action === 'unarchive' ) { | |
| $id = isset( $_POST['actor_id'] ) ? (int) $_POST['actor_id'] : 0; | |
| if ( $id > 0 ) { | |
| if ( $action === 'archive' ) SA_Orch_Actor_Registry::archive_actor( $id ); | |
| if ( $action === 'unarchive' ) SA_Orch_Actor_Registry::unarchive_actor( $id ); | |
| } | |
| wp_safe_redirect( admin_url( 'admin.php?page=' . self::SUBMENU_SLUG . '¬ice=' . $action . 'd' ) ); | |
| exit; | |
| } | |
| } | |
| /** Single-page render with view dispatching. */ | |
| public static function render_page() { | |
| if ( ! current_user_can( SA_Orch_Admin::CAP ) ) { | |
| wp_die( __( 'Insufficient permissions.', 'sa-orchestration' ) ); | |
| } | |
| $view = isset( $_GET['view'] ) ? sanitize_text_field( wp_unslash( $_GET['view'] ) ) : 'list'; | |
| echo '<div class="wrap sa-actors-wrap">'; | |
| echo '<h1>Sleeper Agents <span class="sa-actors-tag">identity-bound roles · non-binding</span></h1>'; | |
| echo self::inline_styles(); | |
| self::render_notice(); | |
| if ( $view === 'create' ) { | |
| self::render_create_form(); | |
| } elseif ( $view === 'detail' ) { | |
| self::render_detail(); | |
| } else { | |
| self::render_list(); | |
| } | |
| echo '</div>'; // .wrap | |
| } | |
| private static function render_notice() { | |
| if ( empty( $_GET['notice'] ) ) return; | |
| $notice = sanitize_text_field( wp_unslash( $_GET['notice'] ) ); | |
| $msg = isset( $_GET['msg'] ) ? sanitize_text_field( wp_unslash( $_GET['msg'] ) ) : ''; | |
| $kind = 'success'; $body = ''; | |
| if ( $notice === 'created' ) { | |
| $body = 'Actor created.'; | |
| } elseif ( $notice === 'archived' ) { | |
| $body = 'Actor archived.'; | |
| } elseif ( $notice === 'unarchived' ) { | |
| $body = 'Actor unarchived.'; | |
| } elseif ( $notice === 'error' ) { | |
| $kind = 'error'; | |
| $body = $msg !== '' ? $msg : 'Action failed.'; | |
| } | |
| if ( $body !== '' ) { | |
| echo '<div class="notice notice-' . esc_attr( $kind ) . ' is-dismissible"><p>' . esc_html( $body ) . '</p></div>'; | |
| } | |
| } | |
| private static function render_list() { | |
| $include_archived = ! empty( $_GET['show_archived'] ); | |
| $actors = SA_Orch_Actor_Registry::list_actors( $include_archived ); | |
| $create_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG . '&view=create' ); | |
| $toggle_archived_url = add_query_arg( 'show_archived', $include_archived ? '0' : '1' ); | |
| echo '<p class="sa-actors-meta">Actors are persistent identity-bound roles. They may speak, draft, advise, observe, coordinate. They may not progress Boards, accept gates, mutate Asterion, or act silently.</p>'; | |
| echo '<p>'; | |
| echo '<a href="' . esc_url( $create_url ) . '" class="button button-primary">+ Create Actor</a> '; | |
| echo '<a href="' . esc_url( $toggle_archived_url ) . '" class="button">' . ( $include_archived ? 'Hide archived' : 'Show archived' ) . '</a>'; | |
| echo '</p>'; | |
| echo '<table class="wp-list-table widefat fixed striped sa-actors-table">'; | |
| echo '<thead><tr>'; | |
| echo '<th>Name</th><th>Type</th><th>Owner</th><th>Status</th><th>Persona (excerpt)</th><th>Created</th><th>Actions</th>'; | |
| echo '</tr></thead><tbody>'; | |
| if ( empty( $actors ) ) { | |
| echo '<tr><td colspan="7" class="sa-actors-empty">No actors yet. <a href="' . esc_url( $create_url ) . '">Create the first one</a> with intent.</td></tr>'; | |
| } else { | |
| foreach ( $actors as $u ) { | |
| $a = SA_Orch_Actor_Registry::get_actor( (int) $u->ID ); | |
| if ( ! $a ) continue; | |
| $detail_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG . '&view=detail&id=' . $a['user_id'] ); | |
| $persona_excerpt = function_exists( 'mb_substr' ) ? mb_substr( $a['persona'], 0, 80 ) : substr( $a['persona'], 0, 80 ); | |
| $status_label = $a['is_active'] ? '<span class="sa-actors-status-active">active</span>' : '<span class="sa-actors-status-archived">archived</span>'; | |
| echo '<tr>'; | |
| echo '<td><strong><a href="' . esc_url( $detail_url ) . '">' . esc_html( $a['name'] ) . '</a></strong><br><small class="sa-actors-login">' . esc_html( $a['login'] ) . '</small></td>'; | |
| echo '<td>' . esc_html( $a['type'] ) . '</td>'; | |
| echo '<td>' . esc_html( $a['owner_login'] ?: '—' ) . '</td>'; | |
| echo '<td>' . $status_label . '</td>'; // status_label is pre-built; safe | |
| echo '<td>' . esc_html( $persona_excerpt ) . ( strlen( $a['persona'] ) > 80 ? '…' : '' ) . '</td>'; | |
| echo '<td>' . esc_html( $a['created_at'] ) . '</td>'; | |
| echo '<td>'; | |
| if ( $a['is_active'] ) { | |
| self::render_post_button( 'archive', $a['user_id'], 'Archive' ); | |
| } else { | |
| self::render_post_button( 'unarchive', $a['user_id'], 'Unarchive' ); | |
| } | |
| echo '</td>'; | |
| echo '</tr>'; | |
| } | |
| } | |
| echo '</tbody></table>'; | |
| } | |
| private static function render_post_button( string $action, int $actor_id, string $label ) { | |
| $url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG ); | |
| echo '<form method="post" action="' . esc_url( $url ) . '" style="display:inline;">'; | |
| wp_nonce_field( 'sa_actor_form', 'sa_actor_nonce' ); | |
| echo '<input type="hidden" name="sa_actor_action" value="' . esc_attr( $action ) . '" />'; | |
| echo '<input type="hidden" name="actor_id" value="' . esc_attr( (string) $actor_id ) . '" />'; | |
| echo '<button type="submit" class="button button-small">' . esc_html( $label ) . '</button>'; | |
| echo '</form>'; | |
| } | |
| private static function render_create_form() { | |
| $list_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG ); | |
| echo '<h2>Create Actor</h2>'; | |
| echo '<p class="sa-actors-meta">Creation discipline: actors must be named with intent, have a defined role, and a scoped purpose. No "random AI assistant" — only purposeful, identity-bound roles.</p>'; | |
| echo '<form method="post" action="' . esc_url( $list_url ) . '" class="sa-actors-form">'; | |
| wp_nonce_field( 'sa_actor_form', 'sa_actor_nonce' ); | |
| echo '<input type="hidden" name="sa_actor_action" value="create" />'; | |
| echo '<table class="form-table"><tbody>'; | |
| echo '<tr><th><label for="actor_name">Name <span class="required">*</span></label></th><td><input id="actor_name" name="actor_name" type="text" class="regular-text" required placeholder="e.g. Bridget — Reconciliation" /></td></tr>'; | |
| echo '<tr><th><label for="actor_type">Type <span class="required">*</span></label></th><td><input id="actor_type" name="actor_type" type="text" class="regular-text" required placeholder="e.g. reconciliation, designer, lawyer, dev" /></td></tr>'; | |
| echo '<tr><th><label for="actor_persona">Persona <span class="required">*</span></label></th><td><textarea id="actor_persona" name="actor_persona" rows="4" class="large-text" required placeholder="Voice + behavior. What this actor is for. e.g. \'System-level coordination actor. Monitors shared objects, highlights divergence, suggests reconciliation.\'"></textarea></td></tr>'; | |
| echo '<tr><th><label for="actor_scope">Scope</label></th><td><textarea id="actor_scope" name="actor_scope" rows="3" class="large-text" placeholder="What surfaces / objects this actor may comment on. e.g. \'cross-board observations\', \'fluent-support tickets\', \'fluent-boards tasks tagged urgent\'"></textarea></td></tr>'; | |
| echo '</tbody></table>'; | |
| echo '<p>'; | |
| echo '<button type="submit" class="button button-primary">Create Actor</button> '; | |
| echo '<a href="' . esc_url( $list_url ) . '" class="button">Cancel</a>'; | |
| echo '</p>'; | |
| echo '</form>'; | |
| } | |
| private static function render_detail() { | |
| $list_url = admin_url( 'admin.php?page=' . self::SUBMENU_SLUG ); | |
| $id = isset( $_GET['id'] ) ? (int) $_GET['id'] : 0; | |
| $a = $id > 0 ? SA_Orch_Actor_Registry::get_actor( $id ) : null; | |
| if ( ! $a ) { | |
| echo '<p class="sa-actors-empty">Actor not found. <a href="' . esc_url( $list_url ) . '">Back to list</a></p>'; | |
| return; | |
| } | |
| echo '<h2>' . esc_html( $a['name'] ) . ' <small class="sa-actors-login">' . esc_html( $a['login'] ) . '</small></h2>'; | |
| echo '<p><a href="' . esc_url( $list_url ) . '">← Back to list</a></p>'; | |
| echo '<table class="form-table"><tbody>'; | |
| echo '<tr><th>Type</th> <td>' . esc_html( $a['type'] ) . '</td></tr>'; | |
| echo '<tr><th>Status</th> <td>' . ( $a['is_active'] ? 'active' : 'archived' ) . '</td></tr>'; | |
| echo '<tr><th>Owner</th> <td>' . esc_html( $a['owner_login'] ?: '—' ) . ' (user_id=' . (int) $a['owner_user_id'] . ')</td></tr>'; | |
| echo '<tr><th>Persona</th> <td><pre class="sa-actors-pre">' . esc_html( $a['persona'] ) . '</pre></td></tr>'; | |
| echo '<tr><th>Scope</th> <td><pre class="sa-actors-pre">' . esc_html( $a['scope'] ?: '—' ) . '</pre></td></tr>'; | |
| echo '<tr><th>Subscription tier</th> <td>' . esc_html( $a['subscription_tier'] ?: '— (reserved for future)' ) . '</td></tr>'; | |
| echo '<tr><th>Created</th> <td>' . esc_html( $a['created_at'] ) . '</td></tr>'; | |
| echo '</tbody></table>'; | |
| echo '<h3>Activity trail</h3>'; | |
| echo '<p class="sa-actors-meta">Actor-attributed comments and drafts will appear here once the invocation surface is implemented (deferred to a future Board). For now: empty by design.</p>'; | |
| } | |
| private static function inline_styles(): string { | |
| return '<style> | |
| .sa-actors-wrap h1 .sa-actors-tag { font-size:11px; padding:2px 6px; background:#f0f0f1; color:#646970; border-radius:3px; vertical-align:middle; font-weight:normal; margin-left:8px; } | |
| .sa-actors-meta { color:#646970; } | |
| .sa-actors-table .sa-actors-login { color:#646970; font-family:Consolas,Monaco,monospace; font-size:11px; } | |
| .sa-actors-status-active { color:#1e4620; background:#edf7ed; padding:2px 6px; border-radius:3px; font-size:11px; } | |
| .sa-actors-status-archived { color:#646970; background:#f0f0f1; padding:2px 6px; border-radius:3px; font-size:11px; } | |
| .sa-actors-empty { color:#646970; font-style:italic; } | |
| .sa-actors-pre { background:#f6f7f7; padding:10px; border-radius:4px; white-space:pre-wrap; max-width:680px; } | |
| .sa-actors-form .required { color:#d63638; } | |
| </style>'; | |
| } | |
| } | |