/ * (b) Filename: -.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//-.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 '
'; echo '

Asterion Seeds — non-canonical seed packets generated by Sara

'; echo '

Sara generates. Asterion validates. Human plants.

'; echo '

Seed root: ' . esc_html( self::seeds_root() ) . '

'; if ( $error !== '' ) { echo '

' . esc_html( $error ) . '

'; } if ( $result ) { echo '

Seed written to ' . esc_html( $result['relative'] ) . ' (' . (int) $result['bytes'] . ' bytes).

'; } echo '

Generate a new seed

'; echo '
'; wp_nonce_field( 'sa_orch_seed_generate' ); echo ''; echo ''; echo ''; echo '
'; echo ''; echo '
'; echo ''; echo '
'; echo '

'; echo '

Generation calls the configured LLM provider (Sara). One LLM call per generation; cost logged in wp_sa_token_log.

'; echo '
'; echo '

Existing seeds (' . count( $seeds ) . ')

'; if ( empty( $seeds ) ) { echo '

No seeds generated yet.

'; } else { echo ''; echo ''; echo ''; foreach ( $seeds as $s ) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
ProjectFileSizeGenerated (UTC)
' . esc_html( $s['project_slug'] ) . '' . esc_html( $s['file'] ) . '' . (int) $s['size'] . '' . esc_html( $s['mtime'] ) . '
'; echo '

Files live on disk under the seed root. No DB index (per resolved question (g)).

'; } echo '
'; } }