File size: 12,431 Bytes
f51c224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
<?php
defined( 'ABSPATH' ) || exit;

/**
 * Asterion Explorer — read-only admin surface for the canonical spec pack.
 *
 * Source of truth: F:/SleeperAgents/SA-orchestration/Archvie/sa-core/docs/specs/
 * (filterable via 'sa_orch_asterion_spec_root').
 *
 * This is a MIRROR of the repo. The repo remains authoritative.
 *   - read-only in WP
 *   - no edits
 *   - no consolidation
 *   - no reclassification
 *   - no mixing with other MD sources
 *
 * Distinct from SA_Orch_Asterion (the dormant ledger writer that targets
 * wp-content/sa-asterion/). The Explorer is human-facing canonical doctrine.
 * The ledger (when activated) is system-generated event records. Different
 * surfaces, different concerns; they coexist without entanglement.
 *
 * Mirror mechanism: live filesystem read at request time.
 *   - No filesystem copy — no drift potential
 *   - No indexed snapshot table — no DB writes
 *   - Repo is automatically authoritative because there is no second copy
 *
 * Sara awareness: NONE in this Board. Deferred to a future Sara Retrieval
 * Layer Board. The Explorer is the human-facing truth surface; cross-agent
 * retrieval is a separate concern with its own design constraints.
 */
class SA_Orch_Asterion_Explorer {

    const SUBMENU_SLUG = 'sa-orchestration-asterion';

    /** Default repo path on this dev machine. Filterable. Override per-deploy via the filter. */
    const DEFAULT_SPEC_ROOT = 'F:/SleeperAgents/SA-orchestration/Archvie/sa-core/docs/specs';

    /**
     * Absolute path to the spec pack root, normalized for this OS.
     * Filter: sa_orch_asterion_spec_root (string $absolute_path)
     */
    public static function spec_root(): string {
        $root = apply_filters( 'sa_orch_asterion_spec_root', self::DEFAULT_SPEC_ROOT );
        return rtrim( str_replace( '\\', '/', (string) $root ), '/' );
    }

    /** Hook into admin_menu via the existing SA_Orch_Admin parent. */
    public static function register_menu() {
        add_submenu_page(
            SA_Orch_Admin::PARENT_SLUG,
            'Asterion',
            'Asterion',
            SA_Orch_Admin::CAP,
            self::SUBMENU_SLUG,
            [ __CLASS__, 'render_page' ]
        );
    }

    /**
     * Render the explorer page: file tree on the left, MD render on the right.
     */
    public static function render_page() {
        if ( ! current_user_can( SA_Orch_Admin::CAP ) ) {
            wp_die( __( 'Insufficient permissions.', 'sa-orchestration' ) );
        }

        $root = self::spec_root();
        $tree = self::build_tree( $root );

        // Selected file from query string. Validated below; never touched
        // without going through validate_relative_path() first.
        $requested = isset( $_GET['file'] ) ? wp_unslash( (string) $_GET['file'] ) : '';
        $resolved  = $requested !== '' ? self::validate_relative_path( $requested ) : null;

        echo '<div class="wrap sa-asterion-wrap">';
        echo '<h1>Asterion <span class="sa-asterion-tag">read-only canonical truth surface</span></h1>';
        echo '<p class="sa-asterion-meta">Source: <code>' . esc_html( $root ) . '</code> &nbsp;·&nbsp; Repo is authoritative. No edits from WP.</p>';

        echo self::inline_styles();

        echo '<div class="sa-asterion-explorer">';

        // ---- Tree ----
        echo '<aside class="sa-asterion-tree">';
        if ( empty( $tree ) ) {
            echo '<p class="sa-asterion-empty">No .md files found at the configured spec root. Verify <code>sa_orch_asterion_spec_root</code> filter / default path is reachable from the WP install.</p>';
        } else {
            self::render_tree_html( $tree, '', $resolved !== null ? $resolved['relative'] : '' );
        }
        echo '</aside>';

        // ---- Content ----
        echo '<section class="sa-asterion-content">';
        if ( $requested !== '' && $resolved === null ) {
            echo '<div class="sa-asterion-error"><strong>Invalid file path.</strong> The requested file is outside the spec root, has a non-.md extension, contains path traversal, or does not exist.</div>';
        } elseif ( $resolved === null ) {
            echo '<div class="sa-asterion-placeholder">Select a file from the tree to view its contents.</div>';
        } else {
            $rendered = self::render_markdown_file( $resolved['absolute'] );
            if ( $rendered === null ) {
                echo '<div class="sa-asterion-error"><strong>Read failed.</strong> The file resolved but could not be read.</div>';
            } else {
                echo '<div class="sa-asterion-breadcrumb"><code>' . esc_html( $resolved['relative'] ) . '</code></div>';
                echo '<article class="sa-asterion-md">' . $rendered . '</article>';
            }
        }
        echo '</section>';

        echo '</div>'; // .sa-asterion-explorer
        echo '</div>'; // .wrap
    }

    /**
     * Build a nested array tree of the spec pack.
     * Returns: [ 'dirs' => [ name => sub-tree ], 'files' => [ name => relative_path ] ]
     */
    private static function build_tree( string $root ): array {
        if ( ! is_dir( $root ) ) {
            return [];
        }
        return self::walk( $root, '' );
    }

    private static function walk( string $abs, string $rel_prefix ): array {
        $dirs  = [];
        $files = [];
        $entries = @scandir( $abs );
        if ( $entries === false ) {
            return [ 'dirs' => [], 'files' => [] ];
        }
        foreach ( $entries as $entry ) {
            if ( $entry === '.' || $entry === '..' ) continue;
            $abs_path = $abs . '/' . $entry;
            $rel_path = $rel_prefix === '' ? $entry : $rel_prefix . '/' . $entry;
            if ( is_dir( $abs_path ) ) {
                $dirs[ $entry ] = self::walk( $abs_path, $rel_path );
            } elseif ( is_file( $abs_path ) && substr( strtolower( $entry ), -3 ) === '.md' ) {
                $files[ $entry ] = $rel_path;
            }
        }
        ksort( $dirs );
        ksort( $files );
        return [ 'dirs' => $dirs, 'files' => $files ];
    }

    private static function render_tree_html( array $tree, string $current_dir_rel, string $selected ) {
        echo '<ul class="sa-asterion-list">';
        foreach ( (array) ( $tree['dirs'] ?? [] ) as $name => $sub ) {
            echo '<li class="sa-asterion-dir"><span class="sa-asterion-dir-label">' . esc_html( $name ) . '/</span>';
            self::render_tree_html( $sub, ( $current_dir_rel === '' ? $name : $current_dir_rel . '/' . $name ), $selected );
            echo '</li>';
        }
        foreach ( (array) ( $tree['files'] ?? [] ) as $name => $rel ) {
            $url      = add_query_arg( [
                'page' => self::SUBMENU_SLUG,
                'file' => $rel,
            ], admin_url( 'admin.php' ) );
            $is_sel   = ( $rel === $selected );
            $cls      = 'sa-asterion-file' . ( $is_sel ? ' sa-asterion-file-selected' : '' );
            echo '<li class="' . esc_attr( $cls ) . '"><a href="' . esc_url( $url ) . '">' . esc_html( $name ) . '</a></li>';
        }
        echo '</ul>';
    }

    /**
     * Validate a relative path against the spec root. Returns
     *   [ 'relative' => normalized, 'absolute' => realpath ]
     * or null on any failure (path traversal, wrong extension, outside root,
     * not a file).
     *
     * Public so other surfaces (Board #28 Sara file binding;
     * Board #30 Asterion projection — both reuse) can validate without
     * re-implementing the rules.
     */
    public static function validate_relative_path( string $relative ): ?array {
        // Normalize separators
        $relative = str_replace( '\\', '/', $relative );

        // Reject any obvious traversal or absolute paths
        if ( strpos( $relative, '..' ) !== false ) return null;
        if ( $relative === '' )                     return null;
        if ( $relative[0] === '/' )                 return null;
        if ( preg_match( '#^[A-Za-z]:#', $relative ) ) return null;

        // Whitelist .md extension only
        if ( substr( strtolower( $relative ), -3 ) !== '.md' ) return null;

        $root_real = realpath( self::spec_root() );
        if ( $root_real === false ) return null;
        $root_real = str_replace( '\\', '/', $root_real );

        $candidate = realpath( self::spec_root() . '/' . $relative );
        if ( $candidate === false ) return null;
        $candidate = str_replace( '\\', '/', $candidate );

        // Final containment check: candidate must be inside root
        if ( strpos( $candidate, $root_real . '/' ) !== 0 ) return null;
        if ( ! is_file( $candidate ) ) return null;

        // Re-derive normalized relative from the realpath for display
        $normalized_rel = ltrim( substr( $candidate, strlen( $root_real ) ), '/' );

        return [
            'relative' => $normalized_rel,
            'absolute' => $candidate,
        ];
    }

    /**
     * Read the file from disk and render via Parsedown in safe mode.
     * Returns rendered HTML, or null on read failure.
     */
    private static function render_markdown_file( string $absolute ): ?string {
        $content = @file_get_contents( $absolute );
        if ( $content === false ) return null;

        if ( ! class_exists( 'Parsedown' ) ) {
            $vendor = dirname( __DIR__ ) . '/vendor/Parsedown.php';
            if ( file_exists( $vendor ) ) {
                require_once $vendor;
            }
        }
        if ( ! class_exists( 'Parsedown' ) ) {
            // Last-resort fallback: HTML-escape and render in <pre>. Honest if ugly.
            return '<pre class="sa-asterion-fallback">' . esc_html( $content ) . '</pre>';
        }

        $pd = new Parsedown();
        if ( method_exists( $pd, 'setSafeMode' ) ) {
            $pd->setSafeMode( true );
        }
        return $pd->text( $content );
    }

    private static function inline_styles(): string {
        // Scoped styles. Keeping it inline so the Board ships in one file.
        return '<style>
            .sa-asterion-wrap h1 .sa-asterion-tag { font-size:11px; padding:2px 6px; background:#f0f0f1; color:#646970; border-radius:3px; vertical-align:middle; font-weight:normal; margin-left:8px; }
            .sa-asterion-meta { color:#646970; font-size:12px; }
            .sa-asterion-explorer { display:flex; gap:16px; margin-top:12px; align-items:flex-start; }
            .sa-asterion-tree { flex:0 0 280px; max-height:80vh; overflow:auto; background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:12px 8px; }
            .sa-asterion-content { flex:1 1 auto; min-width:0; background:#fff; border:1px solid #c3c4c7; border-radius:4px; padding:16px 24px; }
            .sa-asterion-list { margin:0; padding-left:14px; list-style:none; }
            .sa-asterion-list ul { padding-left:14px; }
            .sa-asterion-dir-label { font-weight:600; color:#1d2327; }
            .sa-asterion-file a { display:block; padding:2px 4px; text-decoration:none; color:#2271b1; }
            .sa-asterion-file a:hover { background:#f6f7f7; }
            .sa-asterion-file-selected a { background:#dcdcde; color:#1d2327; font-weight:600; }
            .sa-asterion-breadcrumb { font-size:12px; color:#646970; margin-bottom:12px; padding-bottom:8px; border-bottom:1px solid #f0f0f1; }
            .sa-asterion-placeholder, .sa-asterion-empty { color:#646970; font-style:italic; }
            .sa-asterion-error { background:#fcf0f1; border-left:4px solid #d63638; padding:10px 12px; color:#7a1f23; }
            .sa-asterion-md { line-height:1.6; }
            .sa-asterion-md h1, .sa-asterion-md h2, .sa-asterion-md h3 { margin-top:1.4em; }
            .sa-asterion-md pre { background:#1d2327; color:#f6f7f7; padding:12px; border-radius:4px; overflow:auto; }
            .sa-asterion-md code { background:#f0f0f1; padding:1px 5px; border-radius:3px; font-size:13px; }
            .sa-asterion-md pre code { background:transparent; padding:0; color:inherit; }
            .sa-asterion-md table { border-collapse:collapse; margin:1em 0; }
            .sa-asterion-md th, .sa-asterion-md td { border:1px solid #c3c4c7; padding:6px 10px; }
            .sa-asterion-md blockquote { border-left:4px solid #c3c4c7; margin:1em 0; padding:4px 14px; color:#50575e; }
            .sa-asterion-fallback { white-space:pre-wrap; }
        </style>';
    }
}