Spaces:
Sleeping
Sleeping
| defined( 'ABSPATH' ) || exit; | |
| /** | |
| * Sara — Asterion Seed Generator (Board #26) | |
| * | |
| * Locked principle: | |
| * Sara generates seeds. Asterion validates seeds. Human plants seeds. | |
| * | |
| * A "seed" is structured markdown — project momentum distilled into a | |
| * non-canonical .md file ready for human review and (later) deliberate | |
| * promotion into the Asterion canonical corpus. The seed is NEVER written | |
| * to the canonical spec pack by this system; promotion is a separate, | |
| * explicit human act that lives outside this Board's scope. | |
| * | |
| * 7 INTENT questions resolved (simplest valid implementation): | |
| * (a) Storage: wp-content/sa-asterion-seeds/<project_slug>/ | |
| * (b) Filename: <yyyy-mm-dd>-<topic>.md, with frontmatter | |
| * (c) Trigger: REST endpoint + admin submenu page (no auto-emission) | |
| * (d) Project: Fluent Boards board_id | |
| * (e) Strategy: Sara-mediated synthesis (LLM via SA_Orch_Sara_Service) | |
| * (f) Promote: DEFERRED — out of scope for #26 | |
| * (g) Index: NO new table; pure-filesystem listing | |
| * | |
| * Hard boundaries: | |
| * - No mutation to Archvie/sa-core/docs/specs/ | |
| * - No mutation to fbs_tasks / fbs_comments | |
| * - No new schema; uses filesystem only | |
| * - No fluent state changes | |
| */ | |
| class SA_Orch_Seed_Generator { | |
| const SUBMENU_SLUG = 'sa-orchestration-seeds'; | |
| /* -------------------- Hooks -------------------- */ | |
| public static function register_routes() { | |
| register_rest_route( 'sa-orch/v1', '/seed/generate', [ | |
| 'methods' => 'POST', | |
| 'callback' => [ __CLASS__, 'rest_generate' ], | |
| 'permission_callback' => function () { | |
| return current_user_can( 'manage_options' ); | |
| }, | |
| 'args' => [ | |
| 'board_id' => [ 'type' => 'integer', 'required' => true ], | |
| 'topic' => [ 'type' => 'string', 'required' => false ], | |
| ], | |
| ] ); | |
| register_rest_route( 'sa-orch/v1', '/seed/list', [ | |
| 'methods' => 'GET', | |
| 'callback' => [ __CLASS__, 'rest_list' ], | |
| 'permission_callback' => function () { | |
| return current_user_can( 'manage_options' ); | |
| }, | |
| ] ); | |
| } | |
| public static function register_menu() { | |
| add_submenu_page( | |
| SA_Orch_Admin::PARENT_SLUG, | |
| 'Asterion Seeds', | |
| 'Asterion Seeds', | |
| SA_Orch_Admin::CAP, | |
| self::SUBMENU_SLUG, | |
| [ __CLASS__, 'render_page' ] | |
| ); | |
| } | |
| /* -------------------- Storage paths -------------------- */ | |
| public static function seeds_root(): string { | |
| $base = trailingslashit( WP_CONTENT_DIR ) . 'sa-asterion-seeds'; | |
| $base = apply_filters( 'sa_orch_seed_root', $base ); | |
| return rtrim( str_replace( '\\', '/', (string) $base ), '/' ); | |
| } | |
| private static function ensure_dir( string $abs ): bool { | |
| if ( is_dir( $abs ) ) return true; | |
| return wp_mkdir_p( $abs ); | |
| } | |
| private static function slug( string $s ): string { | |
| $s = sanitize_title( $s ); | |
| $s = $s !== '' ? $s : 'seed'; | |
| return $s; | |
| } | |
| /* -------------------- REST -------------------- */ | |
| public static function rest_generate( WP_REST_Request $request ) { | |
| $board_id = (int) $request->get_param( 'board_id' ); | |
| $topic = sanitize_text_field( (string) $request->get_param( 'topic' ) ); | |
| if ( $board_id <= 0 ) { | |
| return new WP_REST_Response( [ | |
| 'error' => 'invalid_board_id', | |
| 'message' => 'board_id must be a positive integer', | |
| ], 400 ); | |
| } | |
| $result = self::generate( $board_id, $topic ); | |
| if ( is_wp_error( $result ) ) { | |
| return new WP_REST_Response( [ | |
| 'error' => $result->get_error_code(), | |
| 'message' => $result->get_error_message(), | |
| ], 502 ); | |
| } | |
| return new WP_REST_Response( $result, 200 ); | |
| } | |
| public static function rest_list( WP_REST_Request $request ) { | |
| return new WP_REST_Response( [ 'seeds' => self::list_seeds() ], 200 ); | |
| } | |
| /* -------------------- Generation -------------------- */ | |
| /** | |
| * Generate a seed packet for a Fluent Board. | |
| * | |
| * Steps: | |
| * 1. Read project momentum from fbs_tasks (counts by stage, recent activity). | |
| * 2. Build a structured analysis prompt for Sara. | |
| * 3. Call SA_Orch_Sara_Service::invoke() — INTERPRETIVE-mode signals | |
| * embedded in the prompt so synthesis (not paraphrase) is produced. | |
| * 4. Compose final markdown with YAML frontmatter (artifact_class: seed, | |
| * accepted_into_asterion: false) + Sara's synthesis as body. | |
| * NOTE: artifact_class — NOT truth_class. Seeds are non-canonical | |
| * proposed candidates that pre-date truth classification. I2's | |
| * truth_class enum (canonical | projected | cached | inferred | | |
| * derived | synthetic) stays sacred and is not widened to admit | |
| * seeds; seeds get their own classification dimension. | |
| * 5. Write to wp-content/sa-asterion-seeds/<slug>/<date>-<topic>.md. | |
| * 6. Return the file path + relative path + summary stats. | |
| */ | |
| public static function generate( int $board_id, string $topic = '' ) { | |
| global $wpdb; | |
| // 1. Project metadata | |
| $board = $wpdb->get_row( $wpdb->prepare( | |
| "SELECT id, title FROM {$wpdb->prefix}fbs_boards WHERE id = %d", | |
| $board_id | |
| ), ARRAY_A ); | |
| if ( ! $board ) { | |
| return new WP_Error( 'board_not_found', "Board {$board_id} not found." ); | |
| } | |
| // 2. Tasks on this board (parent only, non-archived) | |
| $tasks = $wpdb->get_results( $wpdb->prepare( | |
| "SELECT id, title, stage_id, status, priority, last_completed_at, created_at, updated_at | |
| FROM {$wpdb->prefix}fbs_tasks | |
| WHERE board_id = %d AND (parent_id IS NULL OR parent_id = 0) AND archived_at IS NULL | |
| ORDER BY id ASC", | |
| $board_id | |
| ), ARRAY_A ); | |
| // Stage label map | |
| $stages = $wpdb->get_results( $wpdb->prepare( | |
| "SELECT id, title FROM {$wpdb->prefix}fbs_board_terms WHERE board_id = %d AND type = 'stage'", | |
| $board_id | |
| ), ARRAY_A ); | |
| $slabel = []; | |
| foreach ( $stages as $s ) $slabel[ (int) $s['id'] ] = (string) $s['title']; | |
| // 3. Build momentum analysis (deterministic, pre-LLM) | |
| $momentum = self::build_momentum( $tasks, $slabel ); | |
| // 4. Build prompt for Sara (INTERPRETIVE mode trigger via doctrine vocabulary) | |
| $analysis_block = self::format_momentum_for_prompt( $board, $momentum, $tasks, $slabel ); | |
| $sara_prompt = "Synthesize an Asterion seed packet for the project below.\n" | |
| . "\n" | |
| . "This is a doctrine-grade seed: the output will be a non-canonical\n" | |
| . "markdown candidate for human review and possible promotion into the\n" | |
| . "Asterion canonical spec corpus. Speak in interpretive mode — name\n" | |
| . "the underlying thesis of the project's momentum, identify accepted\n" | |
| . "invariants implied by the closed Boards, surface deferred items as\n" | |
| . "their own structural class, and propose 2–4 specific Asterion\n" | |
| . "additions (each as a one-line proposed canonical addition).\n" | |
| . "\n" | |
| . "Return the structured 4-key Sara shape. Use:\n" | |
| . " summary — 2-4 sentence thesis of the project's current state\n" | |
| . " suggestion — one specific architectural observation worth surfacing to Asterion\n" | |
| . " draft — the proposed Asterion addition(s), markdown-formatted, one or more\n" | |
| . " headed sections (## Proposed addition: …) ready to paste into a spec file\n" | |
| . " notes — open questions, deferred items, attribution flags, low-confidence calls\n" | |
| . "\n" | |
| . "Project analysis (pre-computed):\n" | |
| . "\n" | |
| . $analysis_block; | |
| // 5. Call Sara | |
| $sara = SA_Orch_Sara_Service::invoke( | |
| [ 'surface' => null, 'ref' => null ], | |
| $sara_prompt, | |
| [], | |
| 'sa-orchestration-seeds', | |
| '' | |
| ); | |
| if ( is_wp_error( $sara ) ) { | |
| return $sara; | |
| } | |
| $resp = isset( $sara['response'] ) && is_array( $sara['response'] ) ? $sara['response'] : []; | |
| $log_id = $sara['log_id'] ?? null; | |
| // 6. Compose final MD with frontmatter | |
| $project_slug = self::slug( $board['title'] ?: ('board-' . $board_id) ); | |
| $date_utc = gmdate( 'Y-m-d' ); | |
| $time_utc = gmdate( 'Y-m-d\TH:i:s\Z' ); | |
| $topic_slug = $topic !== '' ? self::slug( $topic ) : ('momentum-' . substr( md5( $time_utc ), 0, 6 )); | |
| $relative = $project_slug . '/' . $date_utc . '-' . $topic_slug . '.md'; | |
| $abs_dir = self::seeds_root() . '/' . $project_slug; | |
| $abs_file = self::seeds_root() . '/' . $relative; | |
| if ( ! self::ensure_dir( $abs_dir ) ) { | |
| return new WP_Error( 'mkdir_failed', "Could not create seed directory {$abs_dir}" ); | |
| } | |
| $md = self::compose_markdown( | |
| $board, | |
| $project_slug, | |
| $time_utc, | |
| $log_id, | |
| $momentum, | |
| $resp | |
| ); | |
| $written = @file_put_contents( $abs_file, $md ); | |
| if ( $written === false ) { | |
| return new WP_Error( 'write_failed', "Could not write seed file {$abs_file}" ); | |
| } | |
| return [ | |
| 'ok' => true, | |
| 'board_id' => $board_id, | |
| 'board_title' => $board['title'], | |
| 'project_slug' => $project_slug, | |
| 'relative' => $relative, | |
| 'absolute' => $abs_file, | |
| 'bytes' => strlen( $md ), | |
| 'log_id' => $log_id, | |
| 'momentum' => $momentum, | |
| 'sara_response'=> $resp, | |
| ]; | |
| } | |
| /* -------------------- Helpers -------------------- */ | |
| private static function build_momentum( array $tasks, array $slabel ): array { | |
| $by_stage = []; | |
| $by_status = []; | |
| $closed_recent = []; | |
| $open_now = []; | |
| $deferred = []; | |
| $now = time(); | |
| $week = 7 * 24 * 3600; | |
| foreach ( $tasks as $t ) { | |
| $sn = $slabel[ (int) $t['stage_id'] ] ?? ('stage_' . $t['stage_id']); | |
| $by_stage[ $sn ] = ( $by_stage[ $sn ] ?? 0 ) + 1; | |
| $by_status[ $t['status'] ] = ( $by_status[ $t['status'] ] ?? 0 ) + 1; | |
| if ( $t['status'] === 'closed' && $t['last_completed_at'] ) { | |
| $ts = strtotime( $t['last_completed_at'] ); | |
| if ( $ts && $now - $ts <= $week ) { | |
| $closed_recent[] = $t; | |
| } | |
| } | |
| if ( $t['status'] !== 'closed' ) { | |
| $open_now[] = $t; | |
| } | |
| if ( strpos( strtolower( (string) $t['title'] ), '[queue]' ) !== false ) { | |
| $deferred[] = $t; | |
| } | |
| } | |
| return [ | |
| 'task_count' => count( $tasks ), | |
| 'by_stage' => $by_stage, | |
| 'by_status' => $by_status, | |
| 'closed_in_week' => count( $closed_recent ), | |
| 'open_now' => count( $open_now ), | |
| 'deferred' => count( $deferred ), | |
| 'closed_recent_titles' => array_map( function ( $t ) { | |
| return [ 'id' => (int) $t['id'], 'title' => (string) $t['title'] ]; | |
| }, $closed_recent ), | |
| 'open_now_titles' => array_map( function ( $t ) { | |
| return [ 'id' => (int) $t['id'], 'title' => (string) $t['title'] ]; | |
| }, $open_now ), | |
| ]; | |
| } | |
| private static function format_momentum_for_prompt( array $board, array $momentum, array $tasks, array $slabel ): string { | |
| $out = "Project: {$board['title']} (board_id={$board['id']})\n"; | |
| $out .= "Total tasks: {$momentum['task_count']}\n"; | |
| $out .= "Closed in last 7 days: {$momentum['closed_in_week']}\n"; | |
| $out .= "Currently open: {$momentum['open_now']}\n"; | |
| $out .= "Deferred ([QUEUE]-tagged): {$momentum['deferred']}\n"; | |
| $out .= "\n"; | |
| $out .= "By stage:\n"; | |
| foreach ( $momentum['by_stage'] as $k => $n ) { | |
| $out .= " {$k}: {$n}\n"; | |
| } | |
| $out .= "\n"; | |
| $out .= "Recently closed (last 7 days):\n"; | |
| if ( empty( $momentum['closed_recent_titles'] ) ) { | |
| $out .= " (none)\n"; | |
| } else { | |
| foreach ( $momentum['closed_recent_titles'] as $r ) { | |
| $out .= " - [#{$r['id']}] {$r['title']}\n"; | |
| } | |
| } | |
| $out .= "\n"; | |
| $out .= "Currently open:\n"; | |
| if ( empty( $momentum['open_now_titles'] ) ) { | |
| $out .= " (none)\n"; | |
| } else { | |
| foreach ( $momentum['open_now_titles'] as $r ) { | |
| $out .= " - [#{$r['id']}] {$r['title']}\n"; | |
| } | |
| } | |
| $out .= "\n"; | |
| return $out; | |
| } | |
| /** | |
| * Build the final seed markdown: | |
| * - YAML frontmatter (artifact_class: seed; accepted_into_asterion: false) | |
| * - Header with project + generation context | |
| * - Momentum block (deterministic stats) | |
| * - Sara synthesis (4 fields rendered as headed sections) | |
| * - Footer noting non-canonical status + planting instructions | |
| * | |
| * artifact_class is intentionally NOT truth_class. Seeds are non-canonical | |
| * proposed candidates predating truth classification. I2's enum stays | |
| * sacred (canonical | projected | cached | inferred | derived | synthetic). | |
| */ | |
| private static function compose_markdown( array $board, string $project_slug, string $time_utc, $log_id, array $momentum, array $sara_resp ): string { | |
| $title = (string) $board['title']; | |
| $bid = (int) $board['id']; | |
| $log = $log_id !== null ? (int) $log_id : 'null'; | |
| $md = "---\n"; | |
| $md .= "artifact_class: seed\n"; | |
| $md .= "accepted_into_asterion: false\n"; | |
| $md .= "project: " . self::yaml_escape( $title ) . "\n"; | |
| $md .= "project_slug: {$project_slug}\n"; | |
| $md .= "board_id: {$bid}\n"; | |
| $md .= "generated_at: {$time_utc}\n"; | |
| $md .= "sara_log_id: {$log}\n"; | |
| $md .= "generator: SA_Orch_Seed_Generator\n"; | |
| $md .= "---\n\n"; | |
| $md .= "# Asterion seed: {$title}\n\n"; | |
| $md .= "> **Non-canonical.** This file is a Sara-generated seed packet for human review.\n"; | |
| $md .= "> It has not been planted into the Asterion canonical spec corpus.\n"; | |
| $md .= "> Promotion is a separate, deliberate act outside this generator's scope.\n\n"; | |
| $md .= "## Project momentum (deterministic)\n\n"; | |
| $md .= "- Total tasks: {$momentum['task_count']}\n"; | |
| $md .= "- Closed in last 7 days: {$momentum['closed_in_week']}\n"; | |
| $md .= "- Currently open: {$momentum['open_now']}\n"; | |
| $md .= "- Deferred ([QUEUE]-tagged): {$momentum['deferred']}\n\n"; | |
| $md .= "By stage:\n\n"; | |
| foreach ( $momentum['by_stage'] as $k => $n ) { | |
| $md .= "- `{$k}`: {$n}\n"; | |
| } | |
| $md .= "\n"; | |
| $md .= "## Sara synthesis (interpretive)\n\n"; | |
| $sum = trim( (string) ( $sara_resp['summary'] ?? '' ) ); | |
| $sug = trim( (string) ( $sara_resp['suggestion'] ?? '' ) ); | |
| $drf = trim( (string) ( $sara_resp['draft'] ?? '' ) ); | |
| $note = trim( (string) ( $sara_resp['notes'] ?? '' ) ); | |
| $md .= "### Thesis\n\n"; | |
| $md .= ( $sum !== '' ? $sum : '_(none)_' ) . "\n\n"; | |
| $md .= "### Architectural observation\n\n"; | |
| $md .= ( $sug !== '' ? $sug : '_(none)_' ) . "\n\n"; | |
| $md .= "### Proposed Asterion addition(s)\n\n"; | |
| $md .= ( $drf !== '' ? $drf : '_(none)_' ) . "\n\n"; | |
| $md .= "### Open questions / deferred / flags\n\n"; | |
| $md .= ( $note !== '' ? $note : '_(none)_' ) . "\n\n"; | |
| $md .= "---\n\n"; | |
| $md .= "## Planting (manual)\n\n"; | |
| $md .= "If accepted: review the proposed additions above, copy any that survive review into\n"; | |
| $md .= "`Archvie/sa-core/docs/specs/` (the canonical corpus), commit, and update this seed's\n"; | |
| $md .= "frontmatter `accepted_into_asterion: true` (the generator will not flip that flag).\n"; | |
| $md .= "If rejected: the seed remains in `wp-content/sa-asterion-seeds/` as a record that the\n"; | |
| $md .= "synthesis was attempted; nothing in canonical Asterion changes.\n"; | |
| return $md; | |
| } | |
| private static function yaml_escape( string $s ): string { | |
| // YAML-safe single-line: wrap in double quotes, escape backslash + quotes. | |
| $s = str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $s ); | |
| return '"' . $s . '"'; | |
| } | |
| /* -------------------- Listing -------------------- */ | |
| /** | |
| * List existing seed files (filesystem-only; no index table per question (g)). | |
| */ | |
| public static function list_seeds(): array { | |
| $root = self::seeds_root(); | |
| if ( ! is_dir( $root ) ) return []; | |
| $out = []; | |
| $projects = @scandir( $root ); | |
| if ( $projects === false ) return []; | |
| foreach ( $projects as $p ) { | |
| if ( $p === '.' || $p === '..' ) continue; | |
| $abs = $root . '/' . $p; | |
| if ( ! is_dir( $abs ) ) continue; | |
| $files = @scandir( $abs ); | |
| if ( $files === false ) continue; | |
| foreach ( $files as $f ) { | |
| if ( $f === '.' || $f === '..' ) continue; | |
| if ( substr( strtolower( $f ), -3 ) !== '.md' ) continue; | |
| $rel = $p . '/' . $f; | |
| $st = @stat( $abs . '/' . $f ); | |
| $out[] = [ | |
| 'project_slug' => $p, | |
| 'file' => $f, | |
| 'relative' => $rel, | |
| 'size' => $st ? (int) $st['size'] : 0, | |
| 'mtime' => $st ? gmdate( 'Y-m-d\TH:i:s\Z', (int) $st['mtime'] ) : '', | |
| ]; | |
| } | |
| } | |
| usort( $out, function ( $a, $b ) { return strcmp( $b['mtime'], $a['mtime'] ); } ); | |
| return $out; | |
| } | |
| /* -------------------- Admin page -------------------- */ | |
| public static function render_page() { | |
| if ( ! current_user_can( SA_Orch_Admin::CAP ) ) wp_die( 'Insufficient permissions.' ); | |
| // Handle synchronous form submission | |
| $result = null; | |
| $error = ''; | |
| if ( isset( $_POST['sa_orch_seed_generate'] ) && check_admin_referer( 'sa_orch_seed_generate' ) ) { | |
| $bid = isset( $_POST['board_id'] ) ? (int) $_POST['board_id'] : 0; | |
| $topic = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : ''; | |
| if ( $bid <= 0 ) { | |
| $error = 'board_id required'; | |
| } else { | |
| $r = self::generate( $bid, $topic ); | |
| if ( is_wp_error( $r ) ) { | |
| $error = $r->get_error_message(); | |
| } else { | |
| $result = $r; | |
| } | |
| } | |
| } | |
| global $wpdb; | |
| $boards = $wpdb->get_results( "SELECT id, title FROM {$wpdb->prefix}fbs_boards ORDER BY id", ARRAY_A ); | |
| $seeds = self::list_seeds(); | |
| echo '<div class="wrap">'; | |
| echo '<h1>Asterion Seeds <span style="font-size:13px;color:#777;font-weight:400;">— non-canonical seed packets generated by Sara</span></h1>'; | |
| echo '<p><em>Sara generates. Asterion validates. Human plants.</em></p>'; | |
| echo '<p>Seed root: <code>' . esc_html( self::seeds_root() ) . '</code></p>'; | |
| if ( $error !== '' ) { | |
| echo '<div class="notice notice-error"><p>' . esc_html( $error ) . '</p></div>'; | |
| } | |
| if ( $result ) { | |
| echo '<div class="notice notice-success"><p>Seed written to <code>' . esc_html( $result['relative'] ) . '</code> (' | |
| . (int) $result['bytes'] . ' bytes).</p></div>'; | |
| } | |
| echo '<h2>Generate a new seed</h2>'; | |
| echo '<form method="post">'; | |
| wp_nonce_field( 'sa_orch_seed_generate' ); | |
| echo '<table class="form-table">'; | |
| echo '<tr><th><label for="sa-orch-seed-board">Project (Fluent Board)</label></th><td>'; | |
| echo '<select id="sa-orch-seed-board" name="board_id" required>'; | |
| echo '<option value="">— select board —</option>'; | |
| foreach ( $boards as $b ) { | |
| echo '<option value="' . (int) $b['id'] . '">[#' . (int) $b['id'] . '] ' . esc_html( $b['title'] ) . '</option>'; | |
| } | |
| echo '</select>'; | |
| echo '</td></tr>'; | |
| echo '<tr><th><label for="sa-orch-seed-topic">Topic (optional, becomes filename slug)</label></th><td>'; | |
| echo '<input id="sa-orch-seed-topic" name="topic" type="text" class="regular-text" placeholder="e.g. midweek-momentum">'; | |
| echo '</td></tr>'; | |
| echo '</table>'; | |
| echo '<p><button type="submit" name="sa_orch_seed_generate" value="1" class="button button-primary">Generate seed</button></p>'; | |
| echo '<p style="color:#666;font-size:12px;">Generation calls the configured LLM provider (Sara). One LLM call per generation; cost logged in <code>wp_sa_token_log</code>.</p>'; | |
| echo '</form>'; | |
| echo '<h2>Existing seeds (' . count( $seeds ) . ')</h2>'; | |
| if ( empty( $seeds ) ) { | |
| echo '<p>No seeds generated yet.</p>'; | |
| } else { | |
| echo '<table class="widefat striped"><thead><tr>'; | |
| echo '<th>Project</th><th>File</th><th>Size</th><th>Generated (UTC)</th>'; | |
| echo '</tr></thead><tbody>'; | |
| foreach ( $seeds as $s ) { | |
| echo '<tr>'; | |
| echo '<td><code>' . esc_html( $s['project_slug'] ) . '</code></td>'; | |
| echo '<td><code>' . esc_html( $s['file'] ) . '</code></td>'; | |
| echo '<td>' . (int) $s['size'] . '</td>'; | |
| echo '<td>' . esc_html( $s['mtime'] ) . '</td>'; | |
| echo '</tr>'; | |
| } | |
| echo '</tbody></table>'; | |
| echo '<p style="color:#666;font-size:12px;">Files live on disk under the seed root. No DB index (per resolved question (g)).</p>'; | |
| } | |
| echo '</div>'; | |
| } | |
| } | |