Skip to main content

algocline_core/
engine_api.rs

1use async_trait::async_trait;
2
3// ─── Parameter types (transport-independent) ─────────────────────
4
5/// A single query response in a batch feed.
6#[derive(Debug)]
7pub struct QueryResponse {
8    /// Query ID (e.g. "q-0", "q-1").
9    pub query_id: String,
10    /// The host LLM's response for this query.
11    pub response: String,
12    /// Token usage reported by the host for this query.
13    pub usage: Option<crate::TokenUsage>,
14}
15
16// ─── Engine API trait ────────────────────────────────────────────
17
18/// Transport-independent API for the algocline engine.
19///
20/// Abstracts the full public surface of AppService so that callers
21/// (MCP handler, future daemon client, etc.) can operate through
22/// `Arc<dyn EngineApi>` without depending on the concrete implementation.
23///
24/// All methods are async to support both local (in-process) and remote
25/// (socket/HTTP) implementations uniformly.
26#[async_trait]
27pub trait EngineApi: Send + Sync {
28    // ─── Core execution ──────────────────────────────────────
29
30    /// Execute Lua code with optional JSON context.
31    ///
32    /// When `host_mode: Some(true)` is passed, the call is proxied via
33    /// `PoolClient` to a long-lived worker subprocess over a Unix domain socket.
34    /// When `host_mode` is `None` or `Some(false)` the existing in-process
35    /// `Executor::start_session` path is used unchanged.
36    async fn run(
37        &self,
38        code: Option<String>,
39        code_file: Option<String>,
40        ctx: Option<serde_json::Value>,
41        project_root: Option<String>,
42        host_mode: Option<bool>,
43    ) -> Result<String, String>;
44
45    /// Apply an installed strategy package. Task is optional.
46    async fn advice(
47        &self,
48        strategy: &str,
49        task: Option<String>,
50        opts: Option<serde_json::Value>,
51        project_root: Option<String>,
52    ) -> Result<String, String>;
53
54    /// Continue a paused execution — single response (with optional query_id).
55    async fn continue_single(
56        &self,
57        session_id: &str,
58        response: String,
59        query_id: Option<&str>,
60        usage: Option<crate::TokenUsage>,
61    ) -> Result<String, String>;
62
63    /// Continue a paused execution — batch feed.
64    async fn continue_batch(
65        &self,
66        session_id: &str,
67        responses: Vec<QueryResponse>,
68    ) -> Result<String, String>;
69
70    // ─── Session status ──────────────────────────────────────
71
72    /// Query active session status.
73    ///
74    /// `pending_filter` is a free-form JSON value forwarded from MCP
75    /// callers, decoded inside the app layer into either a preset name
76    /// (`"meta"` / `"preview"` / `"full"`) or a custom field-filter
77    /// object. `None` keeps the legacy count-only snapshot.
78    ///
79    /// `include_history`: when `true`, each session snapshot includes
80    /// `conversation_history` (capped at 10 entries). Default `false`
81    /// preserves the lightweight snapshot contract for high-frequency pollers.
82    async fn status(
83        &self,
84        session_id: Option<&str>,
85        pending_filter: Option<serde_json::Value>,
86        include_history: bool,
87    ) -> Result<String, String>;
88
89    // ─── Evaluation ──────────────────────────────────────────
90
91    /// Run an evalframe evaluation suite.
92    ///
93    /// `auto_card`: when true, emit an immutable Card
94    /// (`~/.algocline/cards/{strategy}/{card_id}.toml`) summarizing the run.
95    async fn eval(
96        &self,
97        scenario: Option<String>,
98        scenario_file: Option<String>,
99        scenario_name: Option<String>,
100        strategy: &str,
101        strategy_opts: Option<serde_json::Value>,
102        auto_card: bool,
103    ) -> Result<String, String>;
104
105    /// List eval history, optionally filtered by strategy.
106    async fn eval_history(&self, strategy: Option<&str>, limit: usize) -> Result<String, String>;
107
108    /// View a specific eval result by ID.
109    async fn eval_detail(&self, eval_id: &str) -> Result<String, String>;
110
111    /// Compare two eval results with statistical significance testing.
112    async fn eval_compare(&self, eval_id_a: &str, eval_id_b: &str) -> Result<String, String>;
113
114    // ─── Scenarios ───────────────────────────────────────────
115
116    /// List available scenarios.
117    async fn scenario_list(&self) -> Result<String, String>;
118
119    /// Show the content of a named scenario.
120    async fn scenario_show(&self, name: &str) -> Result<String, String>;
121
122    /// Install scenarios from a Git URL or local path.
123    async fn scenario_install(&self, url: String) -> Result<String, String>;
124
125    // ─── Packages ────────────────────────────────────────────
126
127    /// Link a local directory as a project-local package (symlink to cache).
128    ///
129    /// Scope selection:
130    /// - `scope = None` or `Some("global")` — symlink into `~/.algocline/packages/`
131    ///   (visible to all projects).
132    /// - `scope = Some("variant")` — record the path in `alc.local.toml`
133    ///   at the project root (worktree-scoped override, git-ignored). No
134    ///   symlink is created.
135    /// - Any other value → `Err("invalid scope: ...")`.
136    ///
137    /// `project_root` is only consulted when `scope = Some("variant")`.
138    /// If `None`, falls back to `ALC_PROJECT_ROOT` env or ancestor walk
139    /// from cwd.
140    async fn pkg_link(
141        &self,
142        path: String,
143        name: Option<String>,
144        force: Option<bool>,
145        scope: Option<String>,
146        project_root: Option<String>,
147    ) -> Result<String, String>;
148
149    /// List installed packages with metadata.
150    ///
151    /// When `project_root` is provided, project-local packages from `alc.toml`/`alc.lock`
152    /// are included with `scope: "project"`. Global packages carry `scope: "global"`.
153    ///
154    /// Mirrors the list-tool knob contract used by [`Self::hub_search`].
155    /// Parameters are individual JSON-primitive `Option<T>` values so
156    /// the `algocline-core` crate stays free of `algocline-app`-internal
157    /// types; the impl folds them into its `pub(crate) ListOpts` struct.
158    ///
159    /// - `limit` is `Option<i32>` at this layer (MCP/JSON boundary).
160    ///   The impl clamps negative values to 0 and casts to `usize`.
161    ///   `Some(0)` (and thus clamped negatives) means **no limit**
162    ///   (return all entries — empty-means-all idiom); `None` falls
163    ///   back to the tool's default cap.
164    /// - `filter` is a free-form JSON object; it is `Deserialize`d into
165    ///   a `HashMap<String, Value>` inside the app layer. Non-object
166    ///   values are logged via `tracing::warn` and treated as no filter.
167    /// - `fields` / `verbose` drive projection on each entry of the
168    ///   `packages` array; `fields` wins when both are supplied.
169    /// - Top-level keys (`packages`, `search_paths`, `project_root`,
170    ///   `lockfile_path`) are never projected away.
171    #[allow(clippy::too_many_arguments)]
172    async fn pkg_list(
173        &self,
174        project_root: Option<String>,
175        limit: Option<i32>,
176        sort: Option<String>,
177        filter: Option<serde_json::Value>,
178        fields: Option<Vec<String>>,
179        verbose: Option<String>,
180    ) -> Result<String, String>;
181
182    /// Install a package from a Git URL or local path.
183    ///
184    /// `force` (optional, default `false`): Collection mode only — overwrite existing
185    /// packages at dest. Single mode rejects pre-existing dest with an error regardless.
186    async fn pkg_install(
187        &self,
188        url: String,
189        name: Option<String>,
190        force: Option<bool>,
191    ) -> Result<String, String>;
192
193    /// Remove a symlinked package from `~/.algocline/packages/`.
194    ///
195    /// Only removes symlinks; for installed (copied) packages, use `pkg_remove`.
196    async fn pkg_unlink(&self, name: String) -> Result<String, String>;
197
198    /// Remove a package entry, scoped by `scope` (`"project"` /
199    /// `"global"` / `"all"`, default `"project"`).
200    ///
201    /// - `"project"`: remove from `alc.toml` + `alc.lock`. Requires an
202    ///   `alc.toml` via `project_root` or ancestor walk.
203    /// - `"global"`: remove from `~/.algocline/installed.json` only.
204    ///   `project_root` is ignored.
205    /// - `"all"`: remove from both; succeeds if either scope had the entry.
206    ///
207    /// Physical files in `~/.algocline/packages/{name}/` are never deleted.
208    async fn pkg_remove(
209        &self,
210        name: &str,
211        project_root: Option<String>,
212        version: Option<String>,
213        scope: Option<String>,
214    ) -> Result<String, String>;
215
216    /// Heal broken package state by reinstalling entries whose installed
217    /// directory is missing. Other broken kinds (dangling symlink,
218    /// declared-path missing) are surfaced as `unrepairable` with a
219    /// suggested remediation.
220    async fn pkg_repair(
221        &self,
222        name: Option<String>,
223        project_root: Option<String>,
224    ) -> Result<String, String>;
225
226    /// Diagnose package state without side effects.
227    ///
228    /// Read-only counterpart of [`Self::pkg_repair`]. Classifies packages
229    /// into four buckets — `healthy`, `installed_missing`, `symlink_dangling`,
230    /// `path_missing` — and returns the result as a JSON string. No
231    /// filesystem writes, no `pkg_install` calls.
232    ///
233    /// `name` restricts the report to a single package; `None` inspects
234    /// every known package. `project_root` is used for the `alc.toml` /
235    /// `alc.local.toml` pass (falls back to ancestor walk from cwd).
236    async fn pkg_doctor(
237        &self,
238        name: Option<String>,
239        project_root: Option<String>,
240    ) -> Result<String, String>;
241
242    /// Run mlua-lspec tests for a package, a single file, or inline code.
243    ///
244    /// Exactly one of `pkg`, `code_file`, or `code` must be provided; providing
245    /// zero or more than one returns a typed `Err`.
246    ///
247    /// # Arguments
248    ///
249    /// * `pkg` — installed package name; spec files are discovered under
250    ///   `<pkg_root>/<spec_dir>/*_spec.lua` (default `spec_dir = "spec"`).
251    /// * `code_file` — absolute path to a single `.lua` test file.
252    /// * `code` — inline Lua source code containing lspec tests.
253    /// * `spec_dir` — subdirectory within the package root that holds spec files
254    ///   (default `"spec"`). Only used when `pkg` is provided.
255    /// * `filter` — substring filter applied to spec file stems (only when `pkg`
256    ///   is provided).
257    /// * `search_paths` — additional directories appended to `package.path`
258    ///   inside the Lua VM, after auto-resolved paths.
259    /// * `project_root` — optional project root for variant-scope package
260    ///   resolution (`alc.local.toml`). Falls back to ancestor walk from cwd.
261    /// * `auto_search_paths` — when `true` (default) or `None`, auto-prepends
262    ///   parent dirs of all linked/installed packages (installed
263    ///   `~/.algocline/packages/`, `alc.toml` path entries, `alc.local.toml`
264    ///   path entries) to `package.path`. When `false`, no auto-resolve is
265    ///   performed and zero paths are injected. Resolved mapping is returned in
266    ///   the JSON response `resolved_search_paths` field.
267    ///
268    /// # Returns
269    ///
270    /// On success: JSON string `{passed, failed, pending, total, duration_ms,
271    /// spec_files: [{path, passed, failed, total, duration_ms, tests: [{suite,
272    /// name, passed, pending, error}]}], resolved_search_paths: [{name,
273    /// search_dir, source}], search_path_warnings?: [...]}`.
274    ///
275    /// # Errors
276    ///
277    /// * Zero or multiple input sources provided → `"pkg_test: provide exactly
278    ///   one of pkg, code_file, code"`.
279    /// * `pkg` not found → `"pkg_test: package '<name>' not found …"`.
280    /// * No spec files found → `"pkg_test: no spec files found in <path> …"`.
281    /// * mlua VM init failure, I/O errors, or `spawn_blocking` panic → typed
282    ///   `Err` string.
283    // 9 parameters are justified by the MCP wire shape: 3 mutually exclusive
284    // input sources (pkg / code_file / code) plus filtering/path/auto-resolve
285    // options.
286    #[allow(clippy::too_many_arguments)]
287    async fn pkg_test(
288        &self,
289        pkg: Option<String>,
290        code_file: Option<String>,
291        code: Option<String>,
292        spec_dir: Option<String>,
293        filter: Option<String>,
294        search_paths: Option<Vec<String>>,
295        project_root: Option<String>,
296        auto_search_paths: Option<bool>,
297    ) -> Result<String, String>;
298
299    // ─── Logging ─────────────────────────────────────────────
300
301    /// Append a note to a session's log file.
302    async fn add_note(
303        &self,
304        session_id: &str,
305        content: &str,
306        title: Option<&str>,
307    ) -> Result<String, String>;
308
309    /// View session logs.
310    async fn log_view(
311        &self,
312        session_id: Option<&str>,
313        limit: Option<usize>,
314        max_chars: Option<usize>,
315    ) -> Result<String, String>;
316
317    /// Aggregate stats across all logged sessions.
318    async fn stats(
319        &self,
320        strategy_filter: Option<&str>,
321        days: Option<u64>,
322    ) -> Result<String, String>;
323
324    // ─── Project lifecycle ────────────────────────────────────
325
326    /// Initialize `alc.toml` in the given project root.
327    ///
328    /// Creates a minimal `alc.toml` (`[packages]` section only).
329    /// Fails if `alc.toml` already exists (no overwrite).
330    async fn init(&self, project_root: Option<String>) -> Result<String, String>;
331
332    /// Re-resolve all `alc.toml` entries and rewrite `alc.lock`.
333    ///
334    /// Requires an `alc.toml` to be present. Returns resolved count and errors.
335    async fn update(&self, project_root: Option<String>) -> Result<String, String>;
336
337    /// Migrate a legacy `alc.lock` to `alc.toml` + new `alc.lock` format.
338    ///
339    /// Detects legacy format via `linked_at` / `local_dir` fields.
340    /// Backs up the old lock file as `alc.lock.bak`.
341    async fn migrate(&self, project_root: Option<String>) -> Result<String, String>;
342
343    // ─── Package narrative (issue #1778221491-39903) ─────────
344
345    /// Render the narrative markdown for a package on-the-fly.
346    ///
347    /// Extracts the init.lua docstring H2/H3 sections via the embedded
348    /// gendoc pipeline (`extract.split_sections` + `projections.narrative_md`)
349    /// and returns the rendered markdown string.
350    ///
351    /// Returns `Ok(Some(markdown))` when the pkg is found and its init.lua
352    /// is loadable. Returns `Ok(None)` when the pkg is not installed.
353    /// Returns `Err(...)` when the pkg is found but the gendoc pipeline fails
354    /// (e.g. malformed init.lua).
355    async fn pkg_get_narrative_md(&self, name: &str) -> Result<Option<String>, String>;
356
357    // ─── Session activation (issue #1776627475) ──────────────
358
359    /// Activate a session pin for the current MCP connection.
360    ///
361    /// `project_root` is resolved at activation time using the
362    /// existing fallback chain (P > E > W) and cached on the
363    /// `AppService`. Subsequent tool calls without an explicit
364    /// `project_root` argument resolve via P > **S** > E > W,
365    /// where S is this pin (issue #1776627475 §6).
366    ///
367    /// `mode` accepts `"default"` (or `None`) and `"test"`. Unknown
368    /// values return a typed error rather than silent fallback.
369    /// Mode is exposed back to callers so downstream tools can
370    /// adapt behaviour (e.g. scenario test isolation).
371    ///
372    /// Returns a JSON string with `session_id`, `project_root`
373    /// (resolved or `null`), and `mode`.
374    async fn session_new(
375        &self,
376        project_root: Option<String>,
377        mode: Option<String>,
378    ) -> Result<String, String>;
379
380    // ─── Cards ───────────────────────────────────────────────
381
382    /// List Card summaries, optionally filtered by pkg.
383    async fn card_list(&self, pkg: Option<String>) -> Result<String, String>;
384
385    /// Fetch a full Card by id.
386    async fn card_get(&self, card_id: &str) -> Result<String, String>;
387
388    /// Filter/sort Cards using the Prisma-style `where` DSL.
389    ///
390    /// - `pkg`: restricts filesystem scan to a single pkg subdir (I/O hint).
391    /// - `where_`: nested-object predicate (see `card::parse_where`).
392    /// - `order_by`: array of dotted-path sort keys; `-` prefix = desc.
393    /// - `limit` / `offset`: pagination.
394    async fn card_find(
395        &self,
396        pkg: Option<String>,
397        where_: Option<serde_json::Value>,
398        order_by: Option<serde_json::Value>,
399        limit: Option<usize>,
400        offset: Option<usize>,
401    ) -> Result<String, String>;
402
403    /// List aliases, optionally filtered by pkg.
404    async fn card_alias_list(&self, pkg: Option<String>) -> Result<String, String>;
405
406    /// Resolve an alias name to its bound Card and return the full Card JSON.
407    async fn card_get_by_alias(&self, name: &str) -> Result<String, String>;
408
409    /// Bind (or rebind) an alias to a Card.
410    async fn card_alias_set(
411        &self,
412        name: &str,
413        card_id: &str,
414        pkg: Option<String>,
415        note: Option<String>,
416    ) -> Result<String, String>;
417
418    /// Append new top-level fields to an existing Card (additive-only).
419    async fn card_append(&self, card_id: &str, fields: serde_json::Value)
420        -> Result<String, String>;
421
422    /// Install Cards from a Card Collection repo (Git URL or local path).
423    async fn card_install(&self, url: String) -> Result<String, String>;
424
425    /// Read per-case samples from a Card's sidecar JSONL file.
426    ///
427    /// `where_` applies the same Prisma-style DSL used by `card_find`
428    /// to each sample row; offset/limit page the post-filter stream.
429    async fn card_samples(
430        &self,
431        card_id: &str,
432        offset: Option<usize>,
433        limit: Option<usize>,
434        where_: Option<serde_json::Value>,
435    ) -> Result<String, String>;
436
437    /// Walk a Card's lineage tree via `metadata.prior_card_id`.
438    ///
439    /// - `direction`: `"up"` | `"down"` | `"both"` (default `"up"`).
440    /// - `depth`: max traversal depth (default 10).
441    /// - `include_stats`: include each node's `[stats]` section.
442    /// - `relation_filter`: optional list of accepted `prior_relation` values.
443    async fn card_lineage(
444        &self,
445        card_id: &str,
446        direction: Option<String>,
447        depth: Option<usize>,
448        include_stats: Option<bool>,
449        relation_filter: Option<Vec<String>>,
450    ) -> Result<String, String>;
451
452    /// Backfill one subscriber (`sink` URI) with all cards from the
453    /// primary store. Drift-safe: cards already present on the sink are
454    /// skipped (never overwritten). Returns a `SinkBackfillReport`
455    /// serialized as a JSON string.
456    async fn card_sink_backfill(&self, _sink: String, _dry_run: bool) -> Result<String, String> {
457        Err("card_sink_backfill: not implemented by this EngineApi impl".into())
458    }
459
460    /// Run a Card analyzer package over a single Card.
461    ///
462    /// The host loads the Card body + samples sidecar, builds a Lua ctx
463    /// (`{ card, samples, card_id }`), and dispatches to the named pkg
464    /// via `require(pkg).run(ctx)`. The default pkg name is
465    /// `"card_analysis"` — backed by the constant
466    /// `DEFAULT_CARD_ANALYZE_PKG` defined in `algocline_app::service::card`.
467    /// This is an IF promise, not a bundled hard dependency.
468    /// If the pkg is missing the call returns an error.
469    ///
470    /// Sister tool to `advice`: `advice` runs a generic strategy over
471    /// a free-form task, while `card_analyze` runs an analyzer over a
472    /// Card and its samples. The Card domain is owned by the host
473    /// (Card schema parsing + samples sidecar load), not the pkg.
474    async fn card_analyze(&self, _card_id: &str, _pkg: Option<String>) -> Result<String, String> {
475        Err("card_analyze: not implemented by this EngineApi impl".into())
476    }
477
478    /// Publish a Card to a hub repository.
479    ///
480    /// Runs: git clone target_repo to staging → copy card files → git add →
481    /// git commit → git push → hub_reindex.
482    ///
483    /// Credential prerequisite: SSH key or `gh auth login` must be
484    /// configured on the host; returns a typed `CardPublishError::MissingCredentials`
485    /// with actionable guidance if push fails due to authentication.
486    ///
487    /// Push success and reindex failure are surfaced as independent fields:
488    /// a successful push is never rolled back when only reindex fails.
489    async fn card_publish(
490        &self,
491        _card_id: &str,
492        _target_repo: &str,
493        _commit_message: Option<&str>,
494    ) -> Result<String, String> {
495        Err("card_publish: not implemented by this EngineApi impl".into())
496    }
497
498    // ─── Trace (recipe_trace observability) ──────────────────
499
500    /// Scan every Card sample sidecar for rows carrying a `.trace`
501    /// object (produced by the bundled `recipe_trace` pkg via
502    /// `M.card_row(result, case, opts)`) and return an array of hits
503    /// after filter + paging.
504    ///
505    /// Filters:
506    /// - `pkg`: restrict enumeration to one Card `pkg`
507    /// - `min_calls` / `max_calls`: inclusive bounds on `.trace.total_calls`
508    /// - `min_ms` / `max_ms`: inclusive bounds on `.trace.total_trace_ms`
509    /// - `completed`: match `.trace.completed` exactly
510    /// - `offset` / `limit`: page the post-filter stream
511    ///
512    /// Rows without a `.trace.total_calls` field are treated as
513    /// untraced and skipped (do not consume `offset`).
514    #[allow(clippy::too_many_arguments)]
515    async fn trace_query(
516        &self,
517        _pkg: Option<String>,
518        _min_calls: Option<u64>,
519        _max_calls: Option<u64>,
520        _min_ms: Option<f64>,
521        _max_ms: Option<f64>,
522        _completed: Option<bool>,
523        _offset: Option<usize>,
524        _limit: Option<usize>,
525    ) -> Result<String, String> {
526        Err("trace_query: not implemented by this EngineApi impl".into())
527    }
528
529    /// Return a compact delta report comparing two individual trace rows
530    /// (recipe_trace-produced Card samples).
531    ///
532    /// A missing `sample_index` defaults to `0`.  Response shape includes
533    /// both raw `a_trace` / `b_trace` triples and a `delta` object with
534    /// `calls_delta`, `ms_delta`, and `ms_per_call_delta` (`b - a`
535    /// semantics).
536    async fn trace_diff(
537        &self,
538        _a_card_id: &str,
539        _a_sample_index: Option<usize>,
540        _b_card_id: &str,
541        _b_sample_index: Option<usize>,
542    ) -> Result<String, String> {
543        Err("trace_diff: not implemented by this EngineApi impl".into())
544    }
545
546    // ─── Hub ─────────────────────────────────────────────────
547
548    /// Rebuild hub index from a packages directory.
549    ///
550    /// When `source_dir` is provided, scans that directory directly
551    /// (pure metadata, no manifest).  When omitted, scans `~/.algocline/packages/`.
552    async fn hub_reindex(
553        &self,
554        output_path: Option<String>,
555        source_dir: Option<String>,
556    ) -> Result<String, String>;
557
558    /// Generate human-readable documentation artifacts from a hub index.
559    ///
560    /// Runs the embedded Lua `gen_docs` pipeline (originally shipped
561    /// with `algocline-bundled-packages`) against `source_dir`, which
562    /// must contain a fresh `hub_index.json`. Emits
563    /// `narrative/{pkg}.md`, `llms.txt`, `llms-full.txt` under
564    /// `out_dir` (defaults to `{source_dir}/docs`), plus optional
565    /// projections depending on `projections`:
566    ///
567    /// - `"hub"`       → `{out_dir}/hub/{pkg}.json`
568    /// - `"context7"`  → `{source_dir}/context7.json`
569    /// - `"devin"`     → `{source_dir}/.devin/wiki.json`
570    /// - `"lint"`      → run V0 lint pass (warnings only)
571    /// - `"lint_only"` → run lint, skip file generation
572    ///
573    /// `config_path` — optional path to a TOML config file. When omitted,
574    /// the project root's `alc.toml` is auto-explored for `[hub.context7]`
575    /// and `[hub.devin]` sections. Core defaults apply when neither a
576    /// `config_path` nor `alc.toml` provides projection config. Passing a
577    /// `.lua` path is a typed error (retired). See
578    /// `docs/hub-gendoc-config.md` for the full schema.
579    ///
580    /// Projection names are validated strictly and unknown values are
581    /// rejected with `Err("gendoc: unknown projection ...")`.
582    ///
583    /// `lint_strict = true` upgrades lint errors to a hard failure
584    /// (equivalent to the `--strict` CLI flag).
585    ///
586    /// Returns a JSON string containing the collected stdout / stderr
587    /// plus the resolved `source_dir` / `out_dir` for observability.
588    async fn hub_gendoc(
589        &self,
590        source_dir: String,
591        out_dir: Option<String>,
592        projections: Option<Vec<String>>,
593        config_path: Option<String>,
594        lint_strict: Option<bool>,
595    ) -> Result<String, String>;
596
597    /// Run `hub_reindex` followed by `hub_gendoc` as a single facade.
598    ///
599    /// This is a convenience wrapper for downstream hub repositories that
600    /// want to regenerate the index and the public docs in one call. The
601    /// composed response is a JSON object:
602    ///
603    /// ```json
604    /// {
605    ///   "reindex": <hub_reindex response>,
606    ///   "gendoc": <hub_gendoc response>,
607    ///   "preset_catalog_version": "...",
608    ///   "preset": { "name": ..., "catalog_version": ..., "resolved": { ... } }
609    /// }
610    /// ```
611    ///
612    /// Error propagation:
613    ///
614    /// - If `hub_reindex` fails, `hub_dist` returns immediately with
615    ///   `Err("dist: reindex failed: {inner}")` and does not invoke
616    ///   `hub_gendoc`.
617    /// - If `hub_gendoc` fails, the error text includes the reindex JSON
618    ///   that already succeeded:
619    ///   `Err("dist: gendoc failed: {inner}\nreindex result (succeeded): {json}")`.
620    ///   The reindex-side side effects (written `hub_index.json`) are not
621    ///   rolled back.
622    ///
623    /// `output_path` is the `hub_index.json` destination (reindex arg).
624    /// Callers typically pass `{source_dir}/hub_index.json` so the
625    /// subsequent gendoc step can read it back.
626    ///
627    /// Presets (`preset`) are expanded inside `hub_dist` into primitive
628    /// `hub_gendoc` arguments (`projections` / `config_path` /
629    /// `lint_strict`). When `preset` is set, the successful JSON response
630    /// includes a `preset` object with `catalog_version` plus the fully
631    /// resolved knobs for observability.
632    ///
633    /// Merge order (strongest wins):
634    /// 1) explicit MCP arguments (`projections` / `config_path` / `lint_strict`)
635    /// 2) optional `alc.toml` overrides under `[hub.dist.presets.<name>]`
636    ///    (keyed by `project_root`) — only fills **omitted** knobs
637    /// 3) builtin `Current` defaults for the selected preset
638    #[allow(clippy::too_many_arguments)]
639    async fn hub_dist(
640        &self,
641        source_dir: String,
642        output_path: Option<String>,
643        out_dir: Option<String>,
644        preset: Option<String>,
645        project_root: Option<String>,
646        projections: Option<Vec<String>>,
647        config_path: Option<String>,
648        lint_strict: Option<bool>,
649    ) -> Result<String, String>;
650
651    /// Show detailed information for a single package.
652    async fn hub_info(&self, pkg: String) -> Result<String, String>;
653
654    /// Search packages across remote index + local install state.
655    ///
656    /// This trait method mirrors the MCP `alc_hub_search` tool. Parameters
657    /// are deliberately individual JSON-primitive `Option<T>` values
658    /// (rather than an aggregate struct) so that the `algocline-core` crate
659    /// stays free of `algocline-app`-internal types. The `algocline-app`
660    /// side of the impl folds these into its `pub(crate) ListOpts` struct.
661    ///
662    /// - `limit` is `Option<i32>` at this layer (MCP/JSON boundary). The
663    ///   impl casts to `usize` internally.
664    /// - `filter` is a free-form JSON object; it is `Deserialize`d into
665    ///   a `HashMap<String, Value>` inside the app layer.
666    /// - `fields` / `verbose` drive projection; `fields` wins when both
667    ///   are supplied.
668    #[allow(clippy::too_many_arguments)]
669    async fn hub_search(
670        &self,
671        query: Option<String>,
672        category: Option<String>,
673        installed_only: Option<bool>,
674        limit: Option<i32>,
675        sort: Option<String>,
676        filter: Option<serde_json::Value>,
677        fields: Option<Vec<String>>,
678        verbose: Option<String>,
679        local_indices: Option<Vec<String>>,
680    ) -> Result<String, String>;
681
682    // ─── Package scaffold ─────────────────────────────────────
683
684    /// Generate a minimal package skeleton at `<target_dir>/<name>/init.lua`.
685    ///
686    /// Writes an `M.meta` / `M.spec.entries.run` / `M.run` template with a
687    /// pre-filled `alc_shapes_compat` range derived from the embedded
688    /// alc_shapes version.  Optional `category` / `description` are emitted
689    /// as uncommented fields in `M.meta` when provided.
690    ///
691    /// Returns `{ "status": "ok", "path": "...", "bytes_written": N }` on
692    /// success. Typed errors (`NameInvalid`, `AlreadyExists`, `IoError`) are
693    /// propagated via `Err(String)` to the MCP wire response.
694    async fn pkg_scaffold(
695        &self,
696        name: String,
697        target_dir: Option<String>,
698        category: Option<String>,
699        description: Option<String>,
700    ) -> Result<String, String>;
701
702    /// Read the `init.lua` source of an installed package.
703    ///
704    /// Searches global (`~/.algocline/packages/`) and variant
705    /// (`alc.local.toml`) scope in priority order (variant wins).
706    /// Returns the raw Lua source on success, or an `Err(String)` describing
707    /// why the package was not found or could not be read.
708    async fn pkg_read_init_lua(&self, name: &str) -> Result<String, String>;
709
710    /// Read metadata for a single installed package.
711    ///
712    /// Returns the JSON object string for one package entry (the same shape
713    /// `pkg_list` returns inside `packages[*]`). `Err("pkg not found: ...")`
714    /// when the package is unknown.
715    async fn pkg_meta(&self, name: &str) -> Result<String, String>;
716
717    // ─── Settings ────────────────────────────────────────────
718
719    /// Resolve `[setting.<target>]` config across env, project (`alc.toml`/`alc.local.toml`),
720    /// and global (`~/.algocline/config.toml`) layers.
721    ///
722    /// Field-level merge: each field independently selects the highest-priority layer
723    /// that defines it (env > project > global). The returned JSON contains both the
724    /// resolved values and a per-field `sources` map identifying the winning layer.
725    ///
726    /// When `target` is `None`, all `[setting.*]` tables across all layers are returned.
727    /// When `target` is `Some(t)`, only the specified target (snake_case) is returned.
728    ///
729    /// Returns JSON string with shape:
730    /// ```json
731    /// {
732    ///   "resolved": { "journal": { "path": "...", "pkg": true } },
733    ///   "sources":  { "journal": { "path": "env",  "pkg": "global" } }
734    /// }
735    /// ```
736    async fn setting_resolve(&self, target: Option<String>) -> Result<String, String>;
737
738    // ─── State management ────────────────────────────────────
739
740    /// List all state keys within a namespace.
741    ///
742    /// Returns a JSON array of key strings. Returns an empty array when the
743    /// namespace directory does not exist (not an error).
744    ///
745    /// # Arguments
746    /// - `namespace` — state namespace to list (e.g. `"orch"`).
747    ///
748    /// # Returns
749    /// `Ok(JSON-array-string)` on success.
750    ///
751    /// # Errors
752    /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
753    /// `UNSAFE_SEGMENT`, `IO_READ`.
754    async fn state_list(&self, namespace: String) -> Result<String, String>;
755
756    /// Show the full JSON content of a state file.
757    ///
758    /// # Arguments
759    /// - `namespace` — state namespace (e.g. `"orch"`).
760    /// - `key` — state key / task id.
761    ///
762    /// # Returns
763    /// `Ok(JSON-value-string)` on success.
764    ///
765    /// # Errors
766    /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
767    /// `NOT_FOUND`, `UNSAFE_SEGMENT`, `IO_READ`, `SERDE`.
768    async fn state_show(&self, namespace: String, key: String) -> Result<String, String>;
769
770    /// Reset (partially delete) fields or completed steps from a state file.
771    ///
772    /// # Arguments
773    /// - `namespace` — state namespace (e.g. `"orch"`).
774    /// - `key` — state key / task id.
775    /// - `steps` — step names to remove from `data.completed_steps`. `None` means no steps removed.
776    /// - `fields` — top-level field names to remove from `data`. `None` means no fields removed.
777    ///
778    /// # Returns
779    /// `Ok(JSON-object-string)` with shape
780    /// `{"ok":true,"backup_path":"...","steps_removed":<usize>,"steps_input":[...],"fields_removed":<usize>,"fields_input":[...]}`.
781    ///
782    /// # Errors
783    /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
784    /// `NOT_FOUND`, `UNSAFE_SEGMENT`, `IO_BACKUP`, `IO_READ`, `IO_WRITE`, `SERDE`, `SHAPE_INVALID`.
785    async fn state_reset(
786        &self,
787        namespace: String,
788        key: String,
789        steps: Option<Vec<String>>,
790        fields: Option<Vec<String>>,
791    ) -> Result<String, String>;
792
793    /// Write or overwrite a value in the dispatched-layout state store.
794    ///
795    /// The namespace and key must be path-safe segments (ASCII alphanumeric,
796    /// `_`, `-`, `.`; no `..` or empty).  On overwrite, a `.bak` file is
797    /// created atomically before the mutation (Crux §3).
798    ///
799    /// # Arguments
800    /// * `namespace` — subdirectory (state namespace), e.g. `"orch"`
801    /// * `key` — file stem, e.g. `"my-task-id"`
802    /// * `value` — JSON value to store
803    ///
804    /// # Returns
805    /// JSON string `{"ok":true}` on success.
806    ///
807    /// # Errors
808    /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
809    /// `UNSAFE_SEGMENT`, `IO_BACKUP`, `IO_WRITE`.
810    async fn state_set(
811        &self,
812        namespace: String,
813        key: String,
814        value: serde_json::Value,
815    ) -> Result<String, String>;
816
817    /// Delete a value from the dispatched-layout state store.
818    ///
819    /// Returns `{"ok":true,"existed":false}` if the key was absent (idempotent
820    /// no-op).  Returns `{"ok":true,"existed":true}` after copying a `.bak` and
821    /// removing the file (Crux §3 atomicity contract).
822    ///
823    /// # Arguments
824    /// * `namespace` — subdirectory (state namespace), e.g. `"orch"`
825    /// * `key` — file stem, e.g. `"my-task-id"`
826    ///
827    /// # Returns
828    /// JSON string `{"ok":true,"existed":<bool>}` on success.
829    ///
830    /// # Errors
831    /// Structured JSON error string: `{"error":"<CODE>",...}` where CODE is one of
832    /// `UNSAFE_SEGMENT`, `IO_BACKUP`, `IO_WRITE`.
833    async fn state_delete(&self, namespace: String, key: String) -> Result<String, String>;
834
835    // ─── Diagnostics ─────────────────────────────────────────
836
837    /// Show server configuration and diagnostic info.
838    async fn info(&self) -> String;
839
840    // ─── Hub resources ───────────────────────────────────────
841
842    /// Return the aggregated hub index across all registered sources as a JSON string.
843    ///
844    /// Merges the cached `hub_index.json` from every discovered source URL.
845    /// Sources that fail to load produce warnings that are embedded in the
846    /// returned JSON under a `"warnings"` field so the MCP caller can observe
847    /// partial failures.
848    ///
849    /// Returns `Ok(json_string)` where the JSON has shape:
850    /// ```json
851    /// { "schema_version": "hub_index/v0", "packages": [...], "warnings": [...] }
852    /// ```
853    /// Returns `Err(message)` only when the hub registries file itself is
854    /// corrupt (hard I/O failure), making further index discovery impossible.
855    async fn hub_index_aggregate(&self) -> Result<String, String>;
856
857    // ─── Pool management ─────────────────────────────────────────
858
859    /// Ensure pool workers are alive; GC stale entries. Idempotent.
860    ///
861    /// Returns JSON `{"sessions": [...], "pool_version": "..."}`.
862    async fn pool_ensure(&self) -> Result<String, String>;
863
864    /// Return pool worker status (registry.json + live state).
865    ///
866    /// When `sid` is provided, restricts to a single worker.
867    /// Returns JSON `{"sessions": [...], "pool_version": "..."}`.
868    async fn pool_status(&self, sid: Option<String>) -> Result<String, String>;
869
870    /// Send SIGTERM to all workers (`sid=None`) or a single worker.
871    ///
872    /// Returns JSON `{"stopped": [...], "errors": [...]}`.
873    async fn pool_stop(&self, sid: Option<String>) -> Result<String, String>;
874}