Skip to main content

harn_cli/package/
manifest.rs

1use super::errors::PackageError;
2use super::*;
3pub use harn_modules::personas::{
4    PersonaAutonomyTier, PersonaManifestEntry, PersonaStageDecl, PersonaStageExit,
5    PersonaValidationError, ResolvedPersonaManifest,
6};
7
8#[derive(Debug, Clone, Deserialize)]
9pub struct Manifest {
10    pub package: Option<PackageInfo>,
11    #[serde(default)]
12    pub dependencies: HashMap<String, Dependency>,
13    #[serde(default)]
14    pub mcp: Vec<McpServerConfig>,
15    #[serde(default)]
16    pub check: CheckConfig,
17    #[serde(default)]
18    pub workspace: WorkspaceConfig,
19    /// `[registry]` table — lightweight package discovery index
20    /// configuration. The CLI also honors `HARN_PACKAGE_REGISTRY` and
21    /// `--registry` flags for one-off overrides.
22    #[serde(default)]
23    pub registry: PackageRegistryConfig,
24    /// `[skills]` table — per-project skill discovery configuration
25    /// (paths, lookup_order, disable).
26    #[serde(default)]
27    pub skills: SkillsConfig,
28    /// `[[skill.source]]` array-of-tables — declared skill sources
29    /// (filesystem, git, reserved registry).
30    #[serde(default)]
31    pub skill: SkillTables,
32    /// `[capabilities]` section — per-provider-per-model override of
33    /// the shipped capability matrix (`defer_loading`, `tool_search`,
34    /// `prompt_caching`, etc.). Entries under `[[capabilities.provider.<name>]]`
35    /// are prepended to the built-in rules for the same provider so
36    /// early adopters can flag proxied endpoints as supporting tool
37    /// search without waiting for a Harn release. See
38    /// `harn_vm::llm::capabilities` for the rule schema.
39    #[serde(default)]
40    pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
41    /// Stable exported package modules. Keys are the logical import
42    /// suffixes (e.g. `providers/openai`) and values are package-root-
43    /// relative file paths. Consumers import them via `<package>/<key>`.
44    #[serde(default)]
45    pub exports: HashMap<String, String>,
46    /// `[llm]` section — packaged provider definitions, aliases,
47    /// inference rules, tier rules, and model defaults. Uses the same
48    /// schema as `providers.toml`, but merges into the current run
49    /// instead of replacing the global config file.
50    #[serde(default)]
51    pub llm: harn_vm::llm_config::ProvidersConfig,
52    /// `[[hooks]]` array-of-tables — declarative runtime hooks installed
53    /// once per process/thread before execution starts. Matches the
54    /// manifest-extension ABI shape added by `[exports]` / `[llm]`, but
55    /// the handlers themselves live in Harn modules.
56    #[serde(default)]
57    pub hooks: Vec<HookConfig>,
58    /// `[[triggers]]` array-of-tables — declarative event-driven trigger
59    /// registrations that resolve local handlers and predicates from Harn
60    /// modules at load time and preserve remote URI schemes for later
61    /// dispatcher work.
62    #[serde(default)]
63    pub triggers: Vec<TriggerManifestEntry>,
64    /// `[[handoff_routes]]` array-of-tables — declarative handoff route data.
65    /// Route selection stays in Harn stdlib/persona code; the Rust manifest
66    /// loader makes these tenant routes available to that code.
67    #[serde(default)]
68    pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
69    /// `[[providers]]` array-of-tables — provider-specific connector
70    /// overrides used by the orchestrator to load either builtin Rust
71    /// connectors or `.harn` modules as connector implementations.
72    #[serde(default)]
73    pub providers: Vec<ProviderManifestEntry>,
74    /// `[[personas]]` array-of-tables — durable, non-executing agent role
75    /// manifests. Personas bind an entry workflow to tools, capabilities,
76    /// autonomy, budgets, receipts, handoffs, evals, and rollout metadata.
77    #[serde(default)]
78    pub personas: Vec<PersonaManifestEntry>,
79    /// `[connector_contract]` table — deterministic package-local fixtures
80    /// consumed by `harn connector check` for pure-Harn connector packages.
81    #[serde(default, alias = "connector-contract")]
82    pub connector_contract: ConnectorContractConfig,
83    /// `[orchestrator]` table — listener-level controls shared by
84    /// manifest-driven ingress surfaces.
85    #[serde(default)]
86    pub orchestrator: OrchestratorConfig,
87    /// `[rules]` table — `sgconfig`-style structural-rule discovery. Lists the
88    /// directories `harn scan` / `harn codemod` load rules from when no
89    /// explicit `--rule`/`--rule-pack` is given.
90    #[serde(default)]
91    pub rules: RulesConfig,
92    /// `[[contributes]]` array-of-tables — host-surface extension
93    /// contributions (editor languages, preview panes, build profiles,
94    /// commands, themes, …). Harn treats `kind` as a host-owned, namespaced
95    /// string and validates only the envelope plus that each contribution's
96    /// declared `scopes` are covered by `[package].permissions`; the host
97    /// (e.g. a host) interprets the kind-specific payload. New contribution
98    /// kinds therefore need no Harn release. This is the editor-layer twin of
99    /// the agent-layer blocks (`[[providers]]`, `[[personas]]`, `[[hooks]]`):
100    /// one signed package may populate any mix of both.
101    #[serde(default)]
102    pub contributes: Vec<ContributionEntry>,
103}
104
105/// A single `[[contributes]]` host-surface contribution.
106///
107/// ```toml
108/// [[contributes]]
109/// kind = "editor.language"          # host-owned namespaced vocabulary
110/// id = "latex"                      # unique within the package
111/// title = "LaTeX"
112/// when = "*.tex"                    # optional activation predicate (host-interpreted)
113/// scopes = ["workspace:read_text"]  # MUST be a subset of [package].permissions
114/// platforms = ["macos", "linux"]    # optional support/parity matrix; empty = all
115/// # kind-specific keys are captured into `config` and interpreted by the host:
116/// languageId = "latex"
117/// extensions = [".tex", ".sty"]
118/// ```
119#[derive(Debug, Clone, Deserialize, Serialize)]
120pub struct ContributionEntry {
121    /// Host-owned, namespaced contribution kind (e.g. `editor.language`,
122    /// `editor.preview`, `build.profile`, `editor.command`, `editor.theme`).
123    /// Harn does not enumerate kinds — new ones need no release; it only
124    /// requires the value be namespaced (`segment(.segment)+`).
125    pub kind: String,
126    /// Stable identifier, unique across the package's contributions.
127    pub id: String,
128    #[serde(default)]
129    pub title: Option<String>,
130    /// Optional activation predicate the host interprets (a glob, a
131    /// `languageId`, or a host-defined expression). Absent = always available.
132    #[serde(default)]
133    pub when: Option<String>,
134    /// Capability scopes this contribution exercises. Every entry MUST be
135    /// declared in `[package].permissions`; validation fails closed otherwise.
136    #[serde(default)]
137    pub scopes: Vec<String>,
138    /// Optional support/parity matrix — which surfaces/platforms this
139    /// contribution targets (e.g. `macos`, `linux`, `windows`, `ide`, `tui`).
140    /// Empty means "all".
141    #[serde(default)]
142    pub platforms: Vec<String>,
143    /// Kind-specific payload, captured verbatim and interpreted by the host.
144    #[serde(flatten)]
145    pub config: BTreeMap<String, toml::Value>,
146}
147
148impl ContributionEntry {
149    /// `true` when `kind` is a non-empty, dot-namespaced identifier such as
150    /// `editor.language`. Single-segment kinds are rejected so third parties
151    /// cannot squat unprefixed names.
152    pub fn has_namespaced_kind(&self) -> bool {
153        let mut segments = 0usize;
154        for segment in self.kind.split('.') {
155            if segment.is_empty()
156                || !segment
157                    .chars()
158                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
159                || !segment.starts_with(|c: char| c.is_ascii_lowercase())
160            {
161                return false;
162            }
163            segments += 1;
164        }
165        segments >= 2
166    }
167}
168
169/// `[rules]` table — project-local structural-rule discovery (#2843).
170///
171/// ```toml
172/// [rules]
173/// ruleDirs = ["rules", "vendor/rules"]
174/// utilDirs = ["rules/util"]
175/// testConfigs = ["rules/tests"]
176/// nativeRuleDirs = ["target/harn-native-rules"]
177/// ```
178///
179/// Paths are resolved relative to the manifest's directory.
180#[derive(Debug, Clone, Default, Deserialize)]
181pub struct RulesConfig {
182    /// Directories of top-level rule `*.toml` files to load.
183    #[serde(default, alias = "rule-dirs", alias = "ruleDirs")]
184    pub rule_dirs: Vec<String>,
185    /// Directories of utility-rule `*.toml` files (referenced via `matches`).
186    #[serde(default, alias = "util-dirs", alias = "utilDirs")]
187    pub util_dirs: Vec<String>,
188    /// Directories holding rule-test fixtures (for `harn rule test`).
189    #[serde(default, alias = "test-configs", alias = "testConfigs")]
190    pub test_configs: Vec<String>,
191    /// Trusted directories of native lint-rule dynamic libraries.
192    #[serde(
193        default,
194        alias = "native-rule-dirs",
195        alias = "nativeRuleDirs",
196        alias = "native_rule_dirs"
197    )]
198    pub native_rule_dirs: Vec<String>,
199}
200
201#[derive(Debug, Clone, Default, Deserialize)]
202pub struct OrchestratorConfig {
203    #[serde(default, alias = "allowed-origins")]
204    pub allowed_origins: Vec<String>,
205    #[serde(default, alias = "max-body-bytes")]
206    pub max_body_bytes: Option<usize>,
207    #[serde(default)]
208    pub budget: OrchestratorBudgetSpec,
209    #[serde(default)]
210    pub drain: OrchestratorDrainConfig,
211    #[serde(default)]
212    pub pumps: OrchestratorPumpConfig,
213}
214
215#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
216pub struct OrchestratorBudgetSpec {
217    #[serde(default)]
218    pub daily_cost_usd: Option<f64>,
219    #[serde(default)]
220    pub hourly_cost_usd: Option<f64>,
221}
222
223#[derive(Debug, Clone, Deserialize)]
224pub struct OrchestratorDrainConfig {
225    #[serde(default = "default_orchestrator_drain_max_items", alias = "max-items")]
226    pub max_items: usize,
227    #[serde(
228        default = "default_orchestrator_drain_deadline_seconds",
229        alias = "deadline-seconds"
230    )]
231    pub deadline_seconds: u64,
232}
233
234impl Default for OrchestratorDrainConfig {
235    fn default() -> Self {
236        Self {
237            max_items: default_orchestrator_drain_max_items(),
238            deadline_seconds: default_orchestrator_drain_deadline_seconds(),
239        }
240    }
241}
242
243pub(crate) fn default_orchestrator_drain_max_items() -> usize {
244    1024
245}
246
247pub(crate) fn default_orchestrator_drain_deadline_seconds() -> u64 {
248    30
249}
250
251#[derive(Debug, Clone, Deserialize)]
252pub struct OrchestratorPumpConfig {
253    #[serde(
254        default = "default_orchestrator_pump_max_outstanding",
255        alias = "max-outstanding"
256    )]
257    pub max_outstanding: usize,
258}
259
260impl Default for OrchestratorPumpConfig {
261    fn default() -> Self {
262        Self {
263            max_outstanding: default_orchestrator_pump_max_outstanding(),
264        }
265    }
266}
267
268pub(crate) fn default_orchestrator_pump_max_outstanding() -> usize {
269    64
270}
271
272#[derive(Debug, Clone, Deserialize)]
273pub struct HookConfig {
274    pub event: harn_vm::orchestration::HookEvent,
275    #[serde(default = "default_hook_pattern")]
276    pub pattern: String,
277    pub handler: String,
278}
279
280pub(crate) fn default_hook_pattern() -> String {
281    "*".to_string()
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct TriggerManifestEntry {
286    pub id: String,
287    #[serde(default)]
288    pub kind: Option<TriggerKind>,
289    #[serde(default)]
290    pub provider: Option<harn_vm::ProviderId>,
291    #[serde(default, alias = "tier")]
292    pub autonomy_tier: harn_vm::AutonomyTier,
293    #[serde(default, rename = "match")]
294    pub match_: Option<TriggerMatchExpr>,
295    #[serde(default)]
296    pub sources: Vec<TriggerSourceManifestEntry>,
297    #[serde(default)]
298    pub when: Option<String>,
299    #[serde(default)]
300    pub when_budget: Option<TriggerWhenBudgetSpec>,
301    pub handler: String,
302    #[serde(default)]
303    pub dedupe_key: Option<String>,
304    #[serde(default)]
305    pub retry: TriggerRetrySpec,
306    #[serde(default)]
307    pub priority: Option<TriggerPriorityField>,
308    #[serde(default)]
309    pub budget: TriggerBudgetSpec,
310    #[serde(default)]
311    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
312    #[serde(default)]
313    pub throttle: Option<TriggerThrottleManifestSpec>,
314    #[serde(default)]
315    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
316    #[serde(default)]
317    pub debounce: Option<TriggerDebounceManifestSpec>,
318    #[serde(default)]
319    pub singleton: Option<TriggerSingletonManifestSpec>,
320    #[serde(default)]
321    pub batch: Option<TriggerBatchManifestSpec>,
322    #[serde(default)]
323    pub window: Option<TriggerStreamWindowManifestSpec>,
324    #[serde(default, alias = "dlq-alerts")]
325    pub dlq_alerts: Vec<TriggerDlqAlertManifestSpec>,
326    #[serde(default)]
327    pub secrets: BTreeMap<String, String>,
328    #[serde(default)]
329    pub filter: Option<String>,
330    #[serde(flatten, default)]
331    pub kind_specific: BTreeMap<String, toml::Value>,
332}
333
334#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335pub struct TriggerSourceManifestEntry {
336    #[serde(default)]
337    pub id: Option<String>,
338    pub kind: TriggerKind,
339    pub provider: harn_vm::ProviderId,
340    #[serde(default, rename = "match")]
341    pub match_: Option<TriggerMatchExpr>,
342    #[serde(default)]
343    pub dedupe_key: Option<String>,
344    #[serde(default)]
345    pub retry: Option<TriggerRetrySpec>,
346    #[serde(default)]
347    pub priority: Option<TriggerPriorityField>,
348    #[serde(default)]
349    pub budget: Option<TriggerBudgetSpec>,
350    #[serde(default)]
351    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
352    #[serde(default)]
353    pub throttle: Option<TriggerThrottleManifestSpec>,
354    #[serde(default)]
355    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
356    #[serde(default)]
357    pub debounce: Option<TriggerDebounceManifestSpec>,
358    #[serde(default)]
359    pub singleton: Option<TriggerSingletonManifestSpec>,
360    #[serde(default)]
361    pub batch: Option<TriggerBatchManifestSpec>,
362    #[serde(default)]
363    pub window: Option<TriggerStreamWindowManifestSpec>,
364    #[serde(default)]
365    pub secrets: BTreeMap<String, String>,
366    #[serde(default)]
367    pub filter: Option<String>,
368    #[serde(flatten, default)]
369    pub kind_specific: BTreeMap<String, toml::Value>,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "kebab-case")]
374pub enum TriggerKind {
375    Webhook,
376    Cron,
377    Poll,
378    Stream,
379    Predicate,
380    A2aPush,
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
384pub struct TriggerMatchExpr {
385    #[serde(default)]
386    pub events: Vec<String>,
387    #[serde(flatten, default)]
388    pub extra: BTreeMap<String, toml::Value>,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct TriggerRetrySpec {
393    #[serde(default)]
394    pub max: u32,
395    #[serde(default)]
396    pub backoff: TriggerRetryBackoff,
397    #[serde(default = "default_trigger_retention_days")]
398    pub retention_days: u32,
399}
400
401#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "kebab-case")]
403pub enum TriggerRetryBackoff {
404    #[default]
405    Immediate,
406    Svix,
407}
408
409pub(crate) fn default_trigger_retention_days() -> u32 {
410    harn_vm::DEFAULT_INBOX_RETENTION_DAYS
411}
412
413impl Default for TriggerRetrySpec {
414    fn default() -> Self {
415        Self {
416            max: 0,
417            backoff: TriggerRetryBackoff::default(),
418            retention_days: default_trigger_retention_days(),
419        }
420    }
421}
422
423#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
424#[serde(rename_all = "lowercase")]
425pub enum TriggerDispatchPriority {
426    High,
427    #[default]
428    Normal,
429    Low,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(untagged)]
434pub enum TriggerPriorityField {
435    Dispatch(TriggerDispatchPriority),
436    Flow(TriggerPriorityManifestSpec),
437}
438
439#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
440pub struct TriggerBudgetSpec {
441    #[serde(default)]
442    pub max_cost_usd: Option<f64>,
443    #[serde(default, alias = "tokens_max")]
444    pub max_tokens: Option<u64>,
445    #[serde(default)]
446    pub daily_cost_usd: Option<f64>,
447    #[serde(default)]
448    pub hourly_cost_usd: Option<f64>,
449    #[serde(default)]
450    pub max_autonomous_decisions_per_hour: Option<u64>,
451    #[serde(default)]
452    pub max_autonomous_decisions_per_day: Option<u64>,
453    #[serde(default)]
454    pub max_concurrent: Option<u32>,
455    #[serde(default)]
456    pub on_budget_exhausted: harn_vm::TriggerBudgetExhaustionStrategy,
457}
458
459#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
460pub struct TriggerWhenBudgetSpec {
461    #[serde(default)]
462    pub max_cost_usd: Option<f64>,
463    #[serde(default)]
464    pub tokens_max: Option<u64>,
465    #[serde(default)]
466    pub timeout: Option<String>,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct TriggerConcurrencyManifestSpec {
471    #[serde(default)]
472    pub key: Option<String>,
473    pub max: u32,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477pub struct TriggerThrottleManifestSpec {
478    #[serde(default)]
479    pub key: Option<String>,
480    pub period: String,
481    pub max: u32,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub struct TriggerRateLimitManifestSpec {
486    #[serde(default)]
487    pub key: Option<String>,
488    pub period: String,
489    pub max: u32,
490}
491
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
493pub struct TriggerDebounceManifestSpec {
494    pub key: String,
495    pub period: String,
496}
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct TriggerSingletonManifestSpec {
500    #[serde(default)]
501    pub key: Option<String>,
502}
503
504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
505pub struct TriggerBatchManifestSpec {
506    #[serde(default)]
507    pub key: Option<String>,
508    pub size: u32,
509    pub timeout: String,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513pub struct TriggerPriorityManifestSpec {
514    pub key: String,
515    #[serde(default)]
516    pub order: Vec<String>,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "kebab-case")]
521pub enum TriggerStreamWindowMode {
522    Tumbling,
523    Sliding,
524    Session,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
528pub struct TriggerStreamWindowManifestSpec {
529    pub mode: TriggerStreamWindowMode,
530    #[serde(default)]
531    pub key: Option<String>,
532    #[serde(default)]
533    pub size: Option<String>,
534    #[serde(default)]
535    pub every: Option<String>,
536    #[serde(default)]
537    pub gap: Option<String>,
538    #[serde(default)]
539    pub max_items: Option<u32>,
540}
541
542#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
543pub struct TriggerDlqAlertManifestSpec {
544    #[serde(default)]
545    pub destinations: Vec<TriggerDlqAlertDestination>,
546    #[serde(default)]
547    pub threshold: TriggerDlqAlertThreshold,
548}
549
550#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
551pub struct TriggerDlqAlertThreshold {
552    #[serde(default, alias = "entries-in-1h")]
553    pub entries_in_1h: Option<u32>,
554    #[serde(default, alias = "percent-of-dispatches")]
555    pub percent_of_dispatches: Option<f64>,
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559#[serde(tag = "kind", rename_all = "snake_case")]
560pub enum TriggerDlqAlertDestination {
561    Slack {
562        channel: String,
563        #[serde(default)]
564        webhook_url_env: Option<String>,
565    },
566    Email {
567        address: String,
568    },
569    Webhook {
570        url: String,
571        #[serde(default)]
572        headers: BTreeMap<String, String>,
573    },
574}
575
576impl TriggerDlqAlertDestination {
577    pub fn label(&self) -> String {
578        match self {
579            Self::Slack { channel, .. } => format!("slack:{channel}"),
580            Self::Email { address } => format!("email:{address}"),
581            Self::Webhook { url, .. } => format!("webhook:{url}"),
582        }
583    }
584}
585
586#[derive(Debug, Clone, PartialEq, Eq)]
587pub enum TriggerHandlerUri {
588    Local(TriggerFunctionRef),
589    A2a {
590        target: String,
591        allow_cleartext: bool,
592    },
593    Worker {
594        queue: String,
595    },
596    Persona {
597        name: String,
598    },
599    EvalPack {
600        target: String,
601    },
602}
603
604#[derive(Debug, Clone, PartialEq, Eq)]
605pub struct TriggerFunctionRef {
606    pub raw: String,
607    pub module_name: Option<String>,
608    pub function_name: String,
609}
610
611/// `[skills]` table body.
612#[derive(Debug, Default, Clone, Deserialize)]
613#[allow(dead_code)] // `defaults` is parsed per harn#73; default application remains staged.
614pub struct SkillsConfig {
615    /// Additional filesystem roots to scan. Each entry may be a
616    /// literal directory or a glob (`packages/*/skills`). Resolved
617    /// relative to the directory holding harn.toml.
618    #[serde(default)]
619    pub paths: Vec<String>,
620    /// Override priority order. Values are layer labels —
621    /// `cli`, `env`, `project`, `manifest`, `user`, `package`,
622    /// `system`, `host`. Unlisted layers fall through to default
623    /// priority after listed ones.
624    #[serde(default)]
625    pub lookup_order: Vec<String>,
626    /// Disable entire layers. Same label set as `lookup_order`.
627    #[serde(default)]
628    pub disable: Vec<String>,
629    /// Optional remote registry base URL used to resolve
630    /// `<fingerprint>.pub` when a signer is not installed locally.
631    #[serde(default)]
632    pub signer_registry_url: Option<String>,
633    /// `[skills.defaults]` inline sub-table — applied to every
634    /// discovered skill when the field is unset in its SKILL.md
635    /// frontmatter.
636    #[serde(default)]
637    pub defaults: SkillDefaults,
638}
639
640#[derive(Debug, Default, Clone, Deserialize)]
641#[allow(dead_code)] // Parsed per harn#73; loader default application is still staged.
642pub struct SkillDefaults {
643    #[serde(default)]
644    pub tool_search: Option<String>,
645    #[serde(default)]
646    pub always_loaded: Vec<String>,
647}
648
649/// Container for `[[skill.source]]` array-of-tables.
650#[derive(Debug, Default, Clone, Deserialize)]
651pub struct SkillTables {
652    #[serde(default, rename = "source")]
653    pub sources: Vec<SkillSourceEntry>,
654}
655
656/// One `[[skill.source]]` entry. The `registry` variant is accepted
657/// for forward-compat but inert — see issue #73 and `docs/src/skills.md`
658/// for the marketplace timeline.
659#[derive(Debug, Clone, Deserialize)]
660#[serde(tag = "type", rename_all = "lowercase")]
661#[allow(dead_code)] // Git/registry skill sources are manifest-reserved by harn#73.
662pub enum SkillSourceEntry {
663    Fs {
664        path: String,
665        #[serde(default)]
666        namespace: Option<String>,
667    },
668    Git {
669        url: String,
670        #[serde(default)]
671        tag: Option<String>,
672        #[serde(default)]
673        namespace: Option<String>,
674    },
675    Registry {
676        #[serde(default)]
677        url: Option<String>,
678        #[serde(default)]
679        name: Option<String>,
680    },
681}
682
683/// Severity override for preflight diagnostics. `error` (default) fails
684/// `harn check`; `warning` reports but does not fail; `off` suppresses
685/// entirely. Accepted via `[check].preflight_severity` in harn.toml so
686/// repos with hosts that do not expose every capability statically can
687/// keep the checker running on genuine type errors.
688#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
689pub enum PreflightSeverity {
690    #[default]
691    Error,
692    Warning,
693    Off,
694}
695
696impl PreflightSeverity {
697    pub fn from_opt(raw: Option<&str>) -> Self {
698        match raw.map(|s| s.to_ascii_lowercase()) {
699            Some(v) if v == "warning" || v == "warn" => Self::Warning,
700            Some(v) if v == "off" || v == "allow" || v == "silent" => Self::Off,
701            _ => Self::Error,
702        }
703    }
704}
705
706#[derive(Debug, Default, Clone, Deserialize)]
707pub struct CheckConfig {
708    #[serde(default)]
709    pub strict: bool,
710    #[serde(default)]
711    pub strict_types: bool,
712    #[serde(default)]
713    pub disable_rules: Vec<String>,
714    #[serde(default)]
715    pub host_capabilities: HashMap<String, Vec<String>>,
716    #[serde(default, alias = "host_capabilities_file")]
717    pub host_capabilities_path: Option<String>,
718    #[serde(default)]
719    pub bundle_root: Option<String>,
720    /// Downgrade or suppress preflight diagnostics. See
721    /// [`PreflightSeverity`].
722    #[serde(default, alias = "preflight-severity")]
723    pub preflight_severity: Option<String>,
724    /// List of `"capability.operation"` strings that should be accepted
725    /// by preflight without emitting a diagnostic, even if the operation
726    /// is not in the default or loaded capability manifest.
727    #[serde(default, alias = "preflight-allow")]
728    pub preflight_allow: Vec<String>,
729}
730
731#[derive(Debug, Default, Clone, Deserialize)]
732pub struct WorkspaceConfig {
733    /// Directory or file globs (repo-relative) that `harn check --workspace`
734    /// walks to collect the full pipeline tree in one invocation.
735    #[serde(default)]
736    pub pipelines: Vec<String>,
737}
738
739#[derive(Debug, Default, Clone, Deserialize)]
740pub struct PackageRegistryConfig {
741    /// URL or filesystem path to a TOML package index.
742    #[serde(default)]
743    pub url: Option<String>,
744}
745
746#[derive(Debug, Clone, Deserialize)]
747pub struct McpServerConfig {
748    pub name: String,
749    #[serde(default)]
750    pub transport: Option<String>,
751    #[serde(default)]
752    pub command: String,
753    #[serde(default)]
754    pub args: Vec<String>,
755    #[serde(default)]
756    pub env: HashMap<String, String>,
757    #[serde(default)]
758    pub url: String,
759    #[serde(default)]
760    pub auth_token: Option<String>,
761    #[serde(default)]
762    pub token_exchange: Option<harn_vm::mcp_oauth::McpTokenExchangeConfig>,
763    #[serde(default)]
764    pub auth: Option<McpAuthConfig>,
765    #[serde(default)]
766    pub client_id: Option<String>,
767    #[serde(default)]
768    pub client_secret: Option<String>,
769    #[serde(default)]
770    pub scopes: Option<String>,
771    #[serde(default)]
772    pub protocol_version: Option<String>,
773    #[serde(default)]
774    pub protocol_mode: Option<String>,
775    #[serde(default)]
776    pub proxy_server_name: Option<String>,
777    /// When `true`, the server is NOT booted up-front. It boots on the
778    /// first `mcp_call` or on skill activation that declares it in
779    /// `requires_mcp`. See harn#75.
780    #[serde(default)]
781    pub lazy: bool,
782    /// Optional pointer to a Server Card — either an HTTP(S) URL or a
783    /// local filesystem path. When set, `mcp_server_card("name")` reads
784    /// the card from this source (cached per-process with a TTL).
785    #[serde(default)]
786    pub card: Option<String>,
787    /// How long (milliseconds) to keep a lazy server's process alive
788    /// after its last binder releases. 0 / unset → disconnect
789    /// immediately. Ignored for non-lazy servers.
790    #[serde(default, alias = "keep-alive-ms", alias = "keep_alive")]
791    pub keep_alive_ms: Option<u64>,
792}
793
794#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
795pub struct McpAuthConfig {
796    #[serde(default)]
797    pub mode: Option<harn_vm::mcp_auth::OAuthClientAuthMode>,
798    #[serde(default, alias = "client-id")]
799    pub client_id: Option<String>,
800    #[serde(
801        default,
802        alias = "client_secret_id",
803        alias = "client-secret-id",
804        alias = "client_secret_ref",
805        alias = "client-secret-ref"
806    )]
807    pub client_secret_id: Option<String>,
808    #[serde(
809        default,
810        alias = "secret_id",
811        alias = "secret-id",
812        alias = "token-secret-id"
813    )]
814    pub secret_id: Option<String>,
815    #[serde(default, alias = "scope")]
816    pub scopes: Option<String>,
817    #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
818    pub token_endpoint_auth_method: Option<String>,
819}
820
821#[derive(Debug, Clone, Deserialize)]
822#[allow(dead_code)] // Package metadata feeds authoring/publish validation tracked in harn#471.
823pub struct PackageInfo {
824    pub name: Option<String>,
825    pub version: Option<String>,
826    #[serde(default)]
827    pub evals: Vec<String>,
828    #[serde(default)]
829    pub description: Option<String>,
830    #[serde(default)]
831    pub license: Option<String>,
832    #[serde(default)]
833    pub repository: Option<String>,
834    #[serde(default, alias = "harn_version", alias = "harn_version_range")]
835    pub harn: Option<String>,
836    #[serde(default)]
837    pub docs_url: Option<String>,
838    #[serde(default)]
839    pub provenance: Option<String>,
840    /// Human-facing publisher / developer name shown in marketplace surfaces.
841    #[serde(default)]
842    pub publisher: Option<String>,
843    /// Publisher contact (email or URL) shown alongside the publisher name.
844    #[serde(default)]
845    pub contact: Option<String>,
846    /// Optional ISO-8601 authoring date. Mutation dates are better derived
847    /// from the registry/VCS, but a declared creation date is allowed.
848    #[serde(default)]
849    pub created: Option<String>,
850    #[serde(default)]
851    pub permissions: Vec<String>,
852    #[serde(default, alias = "host-requirements")]
853    pub host_requirements: Vec<String>,
854    #[serde(default)]
855    pub tools: Vec<PackageToolExport>,
856    #[serde(default)]
857    pub skills: Vec<PackageSkillExport>,
858}
859
860#[derive(Debug, Clone, Deserialize, PartialEq)]
861pub struct PackageToolExport {
862    pub name: String,
863    pub module: String,
864    #[serde(default = "default_package_tool_symbol")]
865    pub symbol: String,
866    #[serde(default)]
867    pub description: Option<String>,
868    #[serde(default)]
869    pub permissions: Vec<String>,
870    #[serde(default, alias = "host-requirements")]
871    pub host_requirements: Vec<String>,
872    #[serde(default, alias = "input-schema")]
873    pub input_schema: Option<toml::Value>,
874    #[serde(default, alias = "output-schema")]
875    pub output_schema: Option<toml::Value>,
876    #[serde(default)]
877    pub annotations: BTreeMap<String, toml::Value>,
878}
879
880pub(crate) fn default_package_tool_symbol() -> String {
881    "tools".to_string()
882}
883
884#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
885pub struct PackageSkillExport {
886    pub name: String,
887    pub path: String,
888    #[serde(default)]
889    pub description: Option<String>,
890    #[serde(default)]
891    pub permissions: Vec<String>,
892    #[serde(default, alias = "host-requirements")]
893    pub host_requirements: Vec<String>,
894}
895
896#[derive(Debug, Clone, Deserialize)]
897#[serde(untagged)]
898pub enum Dependency {
899    Table(Box<DepTable>),
900    Path(String),
901}
902
903#[derive(Debug, Clone, Default, Deserialize)]
904pub struct DepTable {
905    pub git: Option<String>,
906    #[serde(default, alias = "archive-url", alias = "archive_url")]
907    pub archive: Option<String>,
908    pub tag: Option<String>,
909    pub rev: Option<String>,
910    pub branch: Option<String>,
911    pub version: Option<String>,
912    pub path: Option<String>,
913    pub package: Option<String>,
914    #[serde(default)]
915    pub checksum: Option<String>,
916    /// Registry index URL/path the dependency was originally added from.
917    /// Persisted in the manifest so registry provenance survives
918    /// round-trips and the lockfile can compare against the registry's
919    /// latest version.
920    #[serde(default)]
921    pub registry: Option<String>,
922    /// Registry-side package name (e.g. `@burin/notion-sdk`). May differ
923    /// from the alias and from the git URL's repo name.
924    #[serde(default, alias = "registry-name")]
925    pub registry_name: Option<String>,
926    /// Registry version specifier the dependency was added against.
927    #[serde(default, alias = "registry-version")]
928    pub registry_version: Option<String>,
929}
930
931impl Dependency {
932    pub(crate) fn git_url(&self) -> Option<&str> {
933        match self {
934            Dependency::Table(t) => t.git.as_deref(),
935            Dependency::Path(_) => None,
936        }
937    }
938
939    pub(crate) fn archive_url(&self) -> Option<&str> {
940        match self {
941            Dependency::Table(t) => t.archive.as_deref(),
942            Dependency::Path(_) => None,
943        }
944    }
945
946    pub(crate) fn rev(&self) -> Option<&str> {
947        match self {
948            Dependency::Table(t) => t.rev.as_deref(),
949            Dependency::Path(_) => None,
950        }
951    }
952
953    pub(crate) fn tag(&self) -> Option<&str> {
954        match self {
955            Dependency::Table(t) => t.tag.as_deref(),
956            Dependency::Path(_) => None,
957        }
958    }
959
960    pub(crate) fn branch(&self) -> Option<&str> {
961        match self {
962            Dependency::Table(t) => t.branch.as_deref(),
963            Dependency::Path(_) => None,
964        }
965    }
966
967    pub(crate) fn version(&self) -> Option<&str> {
968        match self {
969            Dependency::Table(t) => t.version.as_deref(),
970            Dependency::Path(_) => None,
971        }
972    }
973
974    pub(crate) fn requires_git(&self) -> bool {
975        self.git_url().is_some()
976    }
977
978    pub(crate) fn local_path(&self) -> Option<&str> {
979        match self {
980            Dependency::Table(t) => t.path.as_deref(),
981            Dependency::Path(p) => Some(p.as_str()),
982        }
983    }
984
985    pub(crate) fn registry_provenance(&self) -> Option<crate::package::RegistryProvenance> {
986        let Dependency::Table(table) = self else {
987            return None;
988        };
989        let source = table.registry.clone()?;
990        let name = table.registry_name.clone()?;
991        let version = table.registry_version.clone()?;
992        Some(crate::package::RegistryProvenance {
993            source,
994            name,
995            version,
996            provenance_url: None,
997        })
998    }
999}
1000
1001pub(crate) fn validate_package_alias(alias: &str) -> Result<(), PackageError> {
1002    if harn_modules::package_snapshot::is_valid_package_name(alias) {
1003        Ok(())
1004    } else {
1005        Err(PackageError::Validation(format!(
1006            "invalid dependency alias {alias:?}; use ASCII letters, numbers, '.', '_' or '-'"
1007        )))
1008    }
1009}
1010
1011pub(crate) fn toml_string_literal(value: &str) -> Result<String, PackageError> {
1012    use std::fmt::Write as _;
1013
1014    let mut encoded = String::with_capacity(value.len() + 2);
1015    encoded.push('"');
1016    for ch in value.chars() {
1017        match ch {
1018            '\u{08}' => encoded.push_str("\\b"),
1019            '\t' => encoded.push_str("\\t"),
1020            '\n' => encoded.push_str("\\n"),
1021            '\u{0C}' => encoded.push_str("\\f"),
1022            '\r' => encoded.push_str("\\r"),
1023            '"' => encoded.push_str("\\\""),
1024            '\\' => encoded.push_str("\\\\"),
1025            ch if ch <= '\u{1F}' || ch == '\u{7F}' => {
1026                write!(&mut encoded, "\\u{:04X}", ch as u32).map_err(|error| {
1027                    PackageError::Manifest(format!("failed to encode TOML string: {error}"))
1028                })?;
1029            }
1030            ch => encoded.push(ch),
1031        }
1032    }
1033    encoded.push('"');
1034    Ok(encoded)
1035}
1036#[derive(Debug, Default, Clone)]
1037pub struct RuntimeExtensions {
1038    pub root_manifest: Option<Manifest>,
1039    pub root_manifest_path: Option<PathBuf>,
1040    pub root_manifest_dir: Option<PathBuf>,
1041    pub(crate) runtime_personas: Vec<ResolvedRuntimePersona>,
1042    pub llm: Option<harn_vm::llm_config::ProvidersConfig>,
1043    pub capabilities: Option<harn_vm::llm::capabilities::CapabilitiesFile>,
1044    pub hooks: Vec<ResolvedHookConfig>,
1045    pub triggers: Vec<ResolvedTriggerConfig>,
1046    pub handoff_routes: Vec<harn_vm::HandoffRouteConfig>,
1047    pub provider_connectors: Vec<ResolvedProviderConnectorConfig>,
1048}
1049
1050#[derive(Debug, Clone, Deserialize)]
1051pub struct ProviderManifestEntry {
1052    pub id: harn_vm::ProviderId,
1053    pub connector: ProviderConnectorManifest,
1054    #[serde(default)]
1055    pub oauth: Option<ProviderOAuthManifest>,
1056    #[serde(default)]
1057    pub setup: Option<ProviderSetupManifest>,
1058    #[serde(default)]
1059    pub capabilities: ConnectorCapabilities,
1060}
1061
1062#[derive(Debug, Clone, Deserialize)]
1063pub struct ProviderConnectorManifest {
1064    #[serde(default)]
1065    pub harn: Option<String>,
1066    #[serde(default)]
1067    pub rust: Option<String>,
1068}
1069
1070#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
1071pub struct ProviderOAuthManifest {
1072    #[serde(default, alias = "auth_url", alias = "authorization-endpoint")]
1073    pub authorization_endpoint: Option<String>,
1074    #[serde(default, alias = "token_url", alias = "token-endpoint")]
1075    pub token_endpoint: Option<String>,
1076    #[serde(default, alias = "registration_url", alias = "registration-endpoint")]
1077    pub registration_endpoint: Option<String>,
1078    #[serde(default)]
1079    pub resource: Option<String>,
1080    #[serde(default, alias = "scope")]
1081    pub scopes: Option<String>,
1082    #[serde(default, alias = "client-id")]
1083    pub client_id: Option<String>,
1084    #[serde(default, alias = "client-secret")]
1085    pub client_secret: Option<String>,
1086    #[serde(default, alias = "token_auth_method", alias = "token-auth-method")]
1087    pub token_endpoint_auth_method: Option<String>,
1088}
1089
1090#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1091pub struct ProviderSetupManifest {
1092    #[serde(default, alias = "auth-type")]
1093    pub auth_type: Option<String>,
1094    #[serde(default)]
1095    pub flow: Option<String>,
1096    #[serde(default, alias = "required-scopes", alias = "scopes")]
1097    pub required_scopes: Vec<String>,
1098    #[serde(default, alias = "required-secrets")]
1099    pub required_secrets: Vec<String>,
1100    #[serde(default, alias = "setup-command")]
1101    pub setup_command: Vec<String>,
1102    #[serde(default, alias = "validation-command")]
1103    pub validation_command: Vec<String>,
1104    #[serde(default, alias = "health-checks")]
1105    pub health_checks: Vec<ConnectorHealthCheckManifest>,
1106    #[serde(default)]
1107    pub recovery: ConnectorRecoveryCopy,
1108    #[serde(flatten, default)]
1109    pub extra: BTreeMap<String, toml::Value>,
1110}
1111
1112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1113pub struct ConnectorHealthCheckManifest {
1114    pub id: String,
1115    pub kind: String,
1116    #[serde(default)]
1117    pub command: Vec<String>,
1118    #[serde(default)]
1119    pub secret: Option<String>,
1120    #[serde(default)]
1121    pub url: Option<String>,
1122}
1123
1124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1125pub struct ConnectorRecoveryCopy {
1126    #[serde(default, alias = "missing-install")]
1127    pub missing_install: Option<String>,
1128    #[serde(default, alias = "missing-auth")]
1129    pub missing_auth: Option<String>,
1130    #[serde(default, alias = "expired-credentials")]
1131    pub expired_credentials: Option<String>,
1132    #[serde(default, alias = "revoked-credentials")]
1133    pub revoked_credentials: Option<String>,
1134    #[serde(default, alias = "missing-scopes")]
1135    pub missing_scopes: Option<String>,
1136    #[serde(default, alias = "inaccessible-resource")]
1137    pub inaccessible_resource: Option<String>,
1138    #[serde(default, alias = "transient-provider-outage")]
1139    pub transient_provider_outage: Option<String>,
1140}
1141
1142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
1143pub struct ConnectorCapabilities {
1144    pub webhook: bool,
1145    pub oauth: bool,
1146    pub rate_limit: bool,
1147    pub pagination: bool,
1148    pub graphql: bool,
1149    pub streaming: bool,
1150}
1151
1152impl ConnectorCapabilities {
1153    pub const FEATURES: [&'static str; 6] = [
1154        "webhook",
1155        "oauth",
1156        "rate_limit",
1157        "pagination",
1158        "graphql",
1159        "streaming",
1160    ];
1161
1162    fn enable(&mut self, feature: &str) -> Result<(), String> {
1163        match normalize_connector_capability(feature).as_str() {
1164            "webhook" => self.webhook = true,
1165            "oauth" => self.oauth = true,
1166            "rate_limit" => self.rate_limit = true,
1167            "pagination" => self.pagination = true,
1168            "graphql" => self.graphql = true,
1169            "streaming" => self.streaming = true,
1170            other => {
1171                return Err(format!(
1172                    "unknown connector capability '{feature}' (normalized as '{other}')"
1173                ));
1174            }
1175        }
1176        Ok(())
1177    }
1178}
1179
1180#[derive(Debug, Default, Deserialize)]
1181struct ConnectorCapabilitiesTable {
1182    #[serde(default)]
1183    webhook: bool,
1184    #[serde(default)]
1185    oauth: bool,
1186    #[serde(default, alias = "rate-limit")]
1187    rate_limit: bool,
1188    #[serde(default)]
1189    pagination: bool,
1190    #[serde(default)]
1191    graphql: bool,
1192    #[serde(default)]
1193    streaming: bool,
1194}
1195
1196impl From<ConnectorCapabilitiesTable> for ConnectorCapabilities {
1197    fn from(value: ConnectorCapabilitiesTable) -> Self {
1198        Self {
1199            webhook: value.webhook,
1200            oauth: value.oauth,
1201            rate_limit: value.rate_limit,
1202            pagination: value.pagination,
1203            graphql: value.graphql,
1204            streaming: value.streaming,
1205        }
1206    }
1207}
1208
1209impl<'de> Deserialize<'de> for ConnectorCapabilities {
1210    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1211    where
1212        D: serde::Deserializer<'de>,
1213    {
1214        #[derive(Deserialize)]
1215        #[serde(untagged)]
1216        enum RawConnectorCapabilities {
1217            List(Vec<String>),
1218            Table(ConnectorCapabilitiesTable),
1219        }
1220
1221        match RawConnectorCapabilities::deserialize(deserializer)? {
1222            RawConnectorCapabilities::List(features) => {
1223                let mut capabilities = ConnectorCapabilities::default();
1224                for feature in features {
1225                    capabilities
1226                        .enable(&feature)
1227                        .map_err(serde::de::Error::custom)?;
1228                }
1229                Ok(capabilities)
1230            }
1231            RawConnectorCapabilities::Table(table) => Ok(table.into()),
1232        }
1233    }
1234}
1235
1236pub fn normalize_connector_capability(feature: &str) -> String {
1237    feature.trim().to_lowercase().replace('-', "_")
1238}
1239
1240#[derive(Debug, Clone, Default, Deserialize)]
1241pub struct ConnectorContractConfig {
1242    #[serde(default)]
1243    pub version: Option<u32>,
1244    #[serde(default)]
1245    pub fixtures: Vec<ConnectorContractFixture>,
1246}
1247
1248#[derive(Debug, Clone, Deserialize)]
1249pub struct ConnectorContractFixture {
1250    pub provider: harn_vm::ProviderId,
1251    #[serde(default)]
1252    pub name: Option<String>,
1253    #[serde(default)]
1254    pub kind: Option<String>,
1255    #[serde(default)]
1256    pub headers: BTreeMap<String, String>,
1257    #[serde(default)]
1258    pub query: BTreeMap<String, String>,
1259    #[serde(default)]
1260    pub metadata: Option<toml::Value>,
1261    #[serde(default)]
1262    pub body: Option<String>,
1263    #[serde(default)]
1264    pub body_json: Option<toml::Value>,
1265    #[serde(default)]
1266    pub expect_type: Option<String>,
1267    #[serde(default)]
1268    pub expect_kind: Option<String>,
1269    #[serde(default)]
1270    pub expect_dedupe_key: Option<String>,
1271    #[serde(default)]
1272    pub expect_signature_state: Option<String>,
1273    #[serde(default)]
1274    pub expect_payload_contains: Option<toml::Value>,
1275    #[serde(default)]
1276    pub expect_response_status: Option<u16>,
1277    #[serde(default)]
1278    pub expect_response_body: Option<toml::Value>,
1279    #[serde(default)]
1280    pub expect_event_count: Option<usize>,
1281    #[serde(default)]
1282    pub expect_error_contains: Option<String>,
1283}
1284
1285#[derive(Debug, Clone, PartialEq, Eq)]
1286pub enum ResolvedProviderConnectorKind {
1287    Harn { module: String },
1288    RustBuiltin,
1289    Invalid(String),
1290}
1291
1292#[derive(Debug, Clone)]
1293pub struct ResolvedProviderConnectorConfig {
1294    pub id: harn_vm::ProviderId,
1295    pub manifest_dir: PathBuf,
1296    pub connector: ResolvedProviderConnectorKind,
1297    pub oauth: Option<ProviderOAuthManifest>,
1298    pub setup: Option<ProviderSetupManifest>,
1299}
1300
1301#[derive(Debug, Clone)]
1302pub struct ResolvedHookConfig {
1303    pub event: harn_vm::orchestration::HookEvent,
1304    pub pattern: String,
1305    pub handler: String,
1306    pub manifest_dir: PathBuf,
1307    pub package_name: Option<String>,
1308    pub exports: HashMap<String, String>,
1309}
1310
1311#[derive(Debug, Clone)]
1312#[allow(dead_code)] // Trigger metadata is carried forward for harn#156 doctor and harn#159 dispatcher work.
1313pub struct ResolvedTriggerConfig {
1314    pub id: String,
1315    pub kind: TriggerKind,
1316    pub provider: harn_vm::ProviderId,
1317    pub autonomy_tier: harn_vm::AutonomyTier,
1318    pub match_: TriggerMatchExpr,
1319    pub when: Option<String>,
1320    pub when_budget: Option<TriggerWhenBudgetSpec>,
1321    pub handler: String,
1322    pub dedupe_key: Option<String>,
1323    pub retry: TriggerRetrySpec,
1324    pub dispatch_priority: TriggerDispatchPriority,
1325    pub budget: TriggerBudgetSpec,
1326    pub concurrency: Option<TriggerConcurrencyManifestSpec>,
1327    pub throttle: Option<TriggerThrottleManifestSpec>,
1328    pub rate_limit: Option<TriggerRateLimitManifestSpec>,
1329    pub debounce: Option<TriggerDebounceManifestSpec>,
1330    pub singleton: Option<TriggerSingletonManifestSpec>,
1331    pub batch: Option<TriggerBatchManifestSpec>,
1332    pub window: Option<TriggerStreamWindowManifestSpec>,
1333    pub priority_flow: Option<TriggerPriorityManifestSpec>,
1334    pub secrets: BTreeMap<String, String>,
1335    pub filter: Option<String>,
1336    pub kind_specific: BTreeMap<String, toml::Value>,
1337    pub manifest_dir: PathBuf,
1338    pub manifest_path: PathBuf,
1339    pub package_name: Option<String>,
1340    pub exports: HashMap<String, String>,
1341    pub table_index: usize,
1342    pub shape_error: Option<String>,
1343}
1344
1345#[derive(Debug, Clone)]
1346#[allow(dead_code)] // Collected bindings are validated now and consumed by harn#159 dispatcher work.
1347pub struct CollectedManifestTrigger {
1348    pub config: ResolvedTriggerConfig,
1349    pub handler: CollectedTriggerHandler,
1350    pub when: Option<CollectedTriggerPredicate>,
1351    pub flow_control: harn_vm::TriggerFlowControlConfig,
1352}
1353
1354#[derive(Debug, Clone)]
1355#[allow(dead_code)] // Remote targets and closures are retained for harn#159 trigger execution.
1356pub enum CollectedTriggerHandler {
1357    Local {
1358        reference: TriggerFunctionRef,
1359        callable: harn_vm::VmCallable,
1360    },
1361    A2a {
1362        target: String,
1363        allow_cleartext: bool,
1364    },
1365    Worker {
1366        queue: String,
1367    },
1368    Persona {
1369        binding: harn_vm::PersonaRuntimeBinding,
1370        callable: harn_vm::VmCallable,
1371    },
1372    EvalPack {
1373        target: String,
1374        manifest: Box<harn_vm::orchestration::EvalPackManifest>,
1375        ledger_options: Option<serde_json::Value>,
1376    },
1377}
1378#[derive(Debug, Clone)]
1379#[allow(dead_code)] // Predicate callables are validated now and reused by harn#161 dispatch gating.
1380pub struct CollectedTriggerPredicate {
1381    pub reference: TriggerFunctionRef,
1382    pub callable: harn_vm::VmCallable,
1383}
1384
1385pub(crate) type ManifestModuleCacheKey = (PathBuf, Option<String>, Option<String>);
1386pub(crate) type ManifestModuleExports = BTreeMap<String, Arc<harn_vm::VmClosure>>;
1387
1388static MANIFEST_PROVIDER_SCHEMA_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
1389
1390pub(crate) async fn lock_manifest_provider_schemas() -> tokio::sync::MutexGuard<'static, ()> {
1391    MANIFEST_PROVIDER_SCHEMA_LOCK
1392        .get_or_init(|| tokio::sync::Mutex::new(()))
1393        .lock()
1394        .await
1395}
1396
1397fn llm_manifest_diagnostics(content: &str) -> Vec<harn_vm::llm_config::ProviderConfigDiagnostic> {
1398    let Ok(value) = toml::from_str::<toml::Value>(content) else {
1399        return Vec::new();
1400    };
1401    let Some(llm) = value.get("llm") else {
1402        return Vec::new();
1403    };
1404    let Ok(llm_src) = toml::to_string(llm) else {
1405        return Vec::new();
1406    };
1407    let Ok(parsed) = harn_vm::llm_config::parse_config_toml_with_diagnostics(&llm_src) else {
1408        return Vec::new();
1409    };
1410    parsed
1411        .diagnostics
1412        .into_iter()
1413        .map(|mut diagnostic| {
1414            if !diagnostic.path.is_empty() {
1415                diagnostic.path = format!("llm.{}", diagnostic.path);
1416            }
1417            diagnostic
1418        })
1419        .collect()
1420}
1421
1422pub(crate) fn read_manifest_from_path(path: &Path) -> Result<Manifest, PackageError> {
1423    let content = fs::read_to_string(path).map_err(|error| {
1424        if error.kind() == std::io::ErrorKind::NotFound {
1425            PackageError::Manifest(format!(
1426                "No {} found in {}.",
1427                MANIFEST,
1428                path.parent().unwrap_or_else(|| Path::new(".")).display()
1429            ))
1430        } else {
1431            PackageError::Manifest(format!("failed to read {}: {error}", path.display()))
1432        }
1433    })?;
1434    let manifest = toml::from_str::<Manifest>(&content).map_err(|error| {
1435        PackageError::Manifest(format!("failed to parse {}: {error}", path.display()))
1436    })?;
1437    for diagnostic in llm_manifest_diagnostics(&content) {
1438        eprintln!("[llm_config] warning in {}: {diagnostic}", path.display());
1439    }
1440    Ok(manifest)
1441}
1442
1443pub(crate) fn write_manifest_content(path: &Path, content: &str) -> Result<(), PackageError> {
1444    harn_vm::atomic_io::atomic_write(path, content.as_bytes()).map_err(|error| {
1445        PackageError::Manifest(format!("failed to write {}: {error}", path.display()))
1446    })
1447}
1448
1449pub(crate) fn absolutize_check_config_paths(
1450    mut config: CheckConfig,
1451    manifest_dir: &Path,
1452) -> CheckConfig {
1453    if let Some(path) = config.host_capabilities_path.clone() {
1454        let candidate = PathBuf::from(&path);
1455        if !candidate.is_absolute() {
1456            config.host_capabilities_path =
1457                Some(manifest_dir.join(candidate).display().to_string());
1458        }
1459    }
1460    if let Some(path) = config.bundle_root.clone() {
1461        let candidate = PathBuf::from(&path);
1462        if !candidate.is_absolute() {
1463            config.bundle_root = Some(manifest_dir.join(candidate).display().to_string());
1464        }
1465    }
1466    config
1467}
1468
1469/// Walk upward from `start` (or its parent if it's a file path that
1470/// does not yet exist) looking for the nearest `harn.toml`. Stops at
1471/// a `.git` boundary so a stray manifest in `$HOME` or a parent
1472/// project is never silently picked up. Returns `(manifest, manifest_dir)`
1473/// when found.
1474pub(crate) fn find_nearest_manifest(start: &Path) -> Option<(Manifest, PathBuf)> {
1475    const MAX_PARENT_DIRS: usize = 16;
1476    let base = if start.is_absolute() {
1477        start.to_path_buf()
1478    } else {
1479        std::env::current_dir()
1480            .unwrap_or_else(|_| PathBuf::from("."))
1481            .join(start)
1482    };
1483    let mut cursor: Option<PathBuf> = if base.is_dir() {
1484        Some(base)
1485    } else {
1486        base.parent().map(Path::to_path_buf)
1487    };
1488    let mut steps = 0usize;
1489    while let Some(dir) = cursor {
1490        if steps >= MAX_PARENT_DIRS {
1491            break;
1492        }
1493        steps += 1;
1494        let candidate = dir.join(MANIFEST);
1495        if candidate.is_file() {
1496            match read_manifest_from_path(&candidate) {
1497                Ok(manifest) => return Some((manifest, dir)),
1498                Err(error) => {
1499                    eprintln!("warning: {error}");
1500                    return None;
1501                }
1502            }
1503        }
1504        if dir.join(".git").exists() {
1505            break;
1506        }
1507        cursor = dir.parent().map(Path::to_path_buf);
1508    }
1509    None
1510}
1511
1512/// Load the `[check]` config from the nearest `harn.toml`.
1513/// Walks up from the given file (or from cwd if no file is given),
1514/// stopping at a `.git` boundary.
1515pub fn load_check_config(harn_file: Option<&std::path::Path>) -> CheckConfig {
1516    let anchor = harn_file
1517        .map(Path::to_path_buf)
1518        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1519    if let Some((manifest, dir)) = find_nearest_manifest(&anchor) {
1520        return absolutize_check_config_paths(manifest.check, &dir);
1521    }
1522    CheckConfig::default()
1523}
1524
1525/// Load the `[workspace]` config and the directory of the `harn.toml`
1526/// it came from. Paths in the returned config are left as-is (callers
1527/// resolve them against the returned `manifest_dir`).
1528pub fn load_workspace_config(anchor: Option<&Path>) -> Option<(WorkspaceConfig, PathBuf)> {
1529    let anchor = anchor
1530        .map(Path::to_path_buf)
1531        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1532    let (manifest, dir) = find_nearest_manifest(&anchor)?;
1533    Some((manifest.workspace, dir))
1534}
1535
1536pub fn load_package_eval_pack_paths(anchor: Option<&Path>) -> Result<Vec<PathBuf>, PackageError> {
1537    let anchor = anchor
1538        .map(Path::to_path_buf)
1539        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1540    let Some((manifest, dir)) = find_nearest_manifest(&anchor) else {
1541        return Err(PackageError::Manifest(
1542            "no harn.toml found for package eval discovery".to_string(),
1543        ));
1544    };
1545
1546    let ctx = ManifestContext { manifest, dir };
1547    let mut paths = eval_pack_paths_from_manifest(&ctx.manifest, &ctx.dir)?;
1548    paths.extend(installed_package_eval_pack_paths(&ctx)?);
1549    paths.sort();
1550    paths.dedup();
1551    if paths.is_empty() {
1552        return Err(PackageError::Manifest(
1553            "package declares no eval packs; add [package].evals, harn.eval.toml, or install a dependency that ships eval packs".to_string(),
1554        ));
1555    }
1556    for path in &paths {
1557        if !path.is_file() {
1558            return Err(PackageError::Manifest(format!(
1559                "eval pack does not exist: {}",
1560                path.display()
1561            )));
1562        }
1563    }
1564    Ok(paths)
1565}
1566
1567fn eval_pack_paths_from_manifest(
1568    manifest: &Manifest,
1569    manifest_dir: &Path,
1570) -> Result<Vec<PathBuf>, PackageError> {
1571    let declared = manifest
1572        .package
1573        .as_ref()
1574        .map(|package| package.evals.clone())
1575        .unwrap_or_default();
1576    let paths = if declared.is_empty() {
1577        let default_pack = manifest_dir.join("harn.eval.toml");
1578        if default_pack.is_file() {
1579            vec![default_pack]
1580        } else {
1581            Vec::new()
1582        }
1583    } else {
1584        declared
1585            .iter()
1586            .map(|entry| {
1587                let path = PathBuf::from(entry);
1588                if path.is_absolute() {
1589                    path
1590                } else {
1591                    manifest_dir.join(path)
1592                }
1593            })
1594            .collect()
1595    };
1596    for path in &paths {
1597        if !path.is_file() {
1598            return Err(PackageError::Manifest(format!(
1599                "eval pack does not exist: {}",
1600                path.display()
1601            )));
1602        }
1603    }
1604    Ok(paths)
1605}
1606
1607fn installed_package_eval_pack_paths(ctx: &ManifestContext) -> Result<Vec<PathBuf>, PackageError> {
1608    let Some(snapshot) = dependency_package_snapshot(&ctx.manifest, &ctx.dir)? else {
1609        return Ok(Vec::new());
1610    };
1611    let lock = LockFile::load(snapshot.lock_path())?.ok_or_else(|| {
1612        PackageError::Lockfile(format!(
1613            "published package generation is missing {}",
1614            snapshot.lock_path().display()
1615        ))
1616    })?;
1617    let mut paths = Vec::new();
1618    let packages_dir = snapshot.packages_root();
1619    for entry in &lock.packages {
1620        validate_package_alias(&entry.name)?;
1621        let package_dir = packages_dir.join(&entry.name);
1622        if package_dir.is_dir() {
1623            if let Some(manifest) = read_package_manifest_from_dir(&package_dir)? {
1624                paths.extend(eval_pack_paths_from_manifest(&manifest, &package_dir)?);
1625            }
1626            continue;
1627        }
1628
1629        let package_file = packages_dir.join(format!("{}.harn", entry.name));
1630        if package_file.is_file() {
1631            continue;
1632        }
1633
1634        return Err(PackageError::Manifest(format!(
1635            "installed package {} is missing under {}; run `harn install`",
1636            entry.name,
1637            packages_dir.display()
1638        )));
1639    }
1640    Ok(paths)
1641}
1642
1643#[derive(Debug, Clone)]
1644pub(crate) struct ManifestContext {
1645    pub(crate) manifest: Manifest,
1646    pub(crate) dir: PathBuf,
1647}
1648
1649impl ManifestContext {
1650    pub(crate) fn manifest_path(&self) -> PathBuf {
1651        self.dir.join(MANIFEST)
1652    }
1653
1654    pub(crate) fn lock_path(&self) -> PathBuf {
1655        self.dir.join(LOCK_FILE)
1656    }
1657}
1658
1659#[derive(Debug, Clone)]
1660pub(crate) struct PackageWorkspace {
1661    manifest_dir: PathBuf,
1662    cache_dir: Option<PathBuf>,
1663    registry_source: Option<String>,
1664    read_process_env: bool,
1665}
1666
1667impl PackageWorkspace {
1668    pub(crate) fn from_current_dir() -> Result<Self, PackageError> {
1669        let manifest_dir = std::env::current_dir()
1670            .map_err(|error| PackageError::Manifest(format!("failed to read cwd: {error}")))?;
1671        Ok(Self {
1672            manifest_dir,
1673            cache_dir: None,
1674            registry_source: None,
1675            read_process_env: true,
1676        })
1677    }
1678
1679    #[cfg(test)]
1680    pub(crate) fn for_test(
1681        manifest_dir: impl Into<PathBuf>,
1682        cache_dir: impl Into<PathBuf>,
1683    ) -> Self {
1684        Self {
1685            manifest_dir: manifest_dir.into(),
1686            cache_dir: Some(cache_dir.into()),
1687            registry_source: None,
1688            read_process_env: false,
1689        }
1690    }
1691
1692    #[cfg(test)]
1693    pub(crate) fn with_registry_source(mut self, source: impl Into<String>) -> Self {
1694        self.registry_source = Some(source.into());
1695        self
1696    }
1697
1698    pub(crate) fn manifest_dir(&self) -> &Path {
1699        &self.manifest_dir
1700    }
1701
1702    pub(crate) fn load_manifest_context(&self) -> Result<ManifestContext, PackageError> {
1703        let manifest_path = self.manifest_dir.join(MANIFEST);
1704        let manifest = read_manifest_from_path(&manifest_path)?;
1705        Ok(ManifestContext {
1706            manifest,
1707            dir: self.manifest_dir.clone(),
1708        })
1709    }
1710
1711    pub(crate) fn cache_root(&self) -> Result<PathBuf, PackageError> {
1712        if let Some(cache_dir) = &self.cache_dir {
1713            return Ok(cache_dir.clone());
1714        }
1715        if self.read_process_env {
1716            if let Ok(value) = std::env::var(HARN_CACHE_DIR_ENV) {
1717                if !value.trim().is_empty() {
1718                    return Ok(PathBuf::from(value));
1719                }
1720            }
1721        }
1722
1723        let home = harn_vm::user_dirs::home_dir().ok_or_else(|| {
1724            "neither HOME nor USERPROFILE is set and HARN_CACHE_DIR was not provided".to_string()
1725        })?;
1726        if cfg!(target_os = "macos") {
1727            return Ok(home.join("Library/Caches/harn"));
1728        }
1729        if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
1730            return Ok(PathBuf::from(xdg).join("harn"));
1731        }
1732        Ok(home.join(".cache/harn"))
1733    }
1734
1735    pub(crate) fn resolve_registry_source(
1736        &self,
1737        explicit: Option<&str>,
1738    ) -> Result<String, PackageError> {
1739        if let Some(explicit) = explicit.map(str::trim).filter(|value| !value.is_empty()) {
1740            return Ok(explicit.to_string());
1741        }
1742        if let Some(source) = self
1743            .registry_source
1744            .as_deref()
1745            .map(str::trim)
1746            .filter(|value| !value.is_empty())
1747        {
1748            if Url::parse(source).is_ok() || PathBuf::from(source).is_absolute() {
1749                return Ok(source.to_string());
1750            }
1751            return Ok(self.manifest_dir.join(source).display().to_string());
1752        }
1753        if self.read_process_env {
1754            if let Ok(value) = std::env::var(HARN_PACKAGE_REGISTRY_ENV) {
1755                let value = value.trim();
1756                if !value.is_empty() {
1757                    return Ok(value.to_string());
1758                }
1759            }
1760        }
1761
1762        if let Some((manifest, manifest_dir)) = find_nearest_manifest(&self.manifest_dir) {
1763            if let Some(raw) = manifest
1764                .registry
1765                .url
1766                .as_deref()
1767                .map(str::trim)
1768                .filter(|value| !value.is_empty())
1769            {
1770                if Url::parse(raw).is_ok() || PathBuf::from(raw).is_absolute() {
1771                    return Ok(raw.to_string());
1772                }
1773                return Ok(manifest_dir.join(raw).display().to_string());
1774            }
1775        }
1776
1777        Ok(DEFAULT_PACKAGE_REGISTRY_URL.to_string())
1778    }
1779}
1780
1781#[cfg(test)]
1782mod tests {
1783    use super::*;
1784    use crate::package::test_support::{current_packages_dir, TestWorkspace};
1785
1786    #[test]
1787    fn rules_table_parses_camel_and_kebab_dir_keys() {
1788        // The documented `ruleDirs` camelCase form and the kebab alias both map
1789        // to `rule_dirs` (#2843).
1790        let camel: Manifest =
1791            toml::from_str("[rules]\nruleDirs = [\"rules\", \"vendor/rules\"]\n").unwrap();
1792        assert_eq!(camel.rules.rule_dirs, vec!["rules", "vendor/rules"]);
1793
1794        let kebab: Manifest = toml::from_str("[rules]\nrule-dirs = [\"r\"]\n").unwrap();
1795        assert_eq!(kebab.rules.rule_dirs, vec!["r"]);
1796
1797        let native: Manifest =
1798            toml::from_str("[rules]\nnativeRuleDirs = [\"native-rules\"]\n").unwrap();
1799        assert_eq!(native.rules.native_rule_dirs, vec!["native-rules"]);
1800
1801        let native_kebab: Manifest =
1802            toml::from_str("[rules]\nnative-rule-dirs = [\"nr\"]\n").unwrap();
1803        assert_eq!(native_kebab.rules.native_rule_dirs, vec!["nr"]);
1804
1805        // No `[rules]` table → empty discovery, never an error.
1806        let none: Manifest = toml::from_str("[package]\nname = \"x\"\n").unwrap();
1807        assert!(none.rules.rule_dirs.is_empty());
1808        assert!(none.rules.native_rule_dirs.is_empty());
1809    }
1810
1811    #[test]
1812    fn llm_manifest_diagnostics_report_unknown_model_fields() {
1813        let diagnostics = llm_manifest_diagnostics(
1814            r#"
1815[llm.models."demo/model"]
1816name = "Demo"
1817provider = "demo"
1818context_window = 4096
1819fast_mode = true
1820"#,
1821        );
1822        let texts: Vec<String> = diagnostics
1823            .into_iter()
1824            .map(|diagnostic| diagnostic.to_string())
1825            .collect();
1826        assert!(
1827            texts.iter().any(
1828                |diagnostic| diagnostic.contains("llm.models.demo/model.fast_mode")
1829                    && diagnostic.contains("serving_tiers")
1830            ),
1831            "expected manifest [llm] unknown-field diagnostic, got {texts:?}"
1832        );
1833    }
1834
1835    #[test]
1836    fn package_eval_pack_paths_use_package_manifest_entries() {
1837        let tmp = tempfile::tempdir().unwrap();
1838        let root = tmp.path();
1839        fs::create_dir_all(root.join(".git")).unwrap();
1840        fs::create_dir_all(root.join("evals")).unwrap();
1841        fs::write(
1842            root.join(MANIFEST),
1843            r#"
1844    [package]
1845    name = "demo"
1846    version = "0.1.0"
1847    evals = ["evals/webhook.toml"]
1848    "#,
1849        )
1850        .unwrap();
1851        fs::write(
1852            root.join("evals/webhook.toml"),
1853            "version = 1\n[[cases]]\nrun = \"run.json\"\n",
1854        )
1855        .unwrap();
1856
1857        let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1858
1859        assert_eq!(paths, vec![root.join("evals/webhook.toml")]);
1860        assert!(
1861            !root.join(".harn").exists(),
1862            "loading project eval packs without dependencies must remain read-only"
1863        );
1864    }
1865
1866    #[test]
1867    fn package_eval_pack_paths_include_installed_package_evals() {
1868        let dependency_tmp = tempfile::tempdir().unwrap();
1869        let dependency = dependency_tmp.path().join("coding-pack");
1870        fs::create_dir_all(dependency.join("evals")).unwrap();
1871        fs::write(
1872            dependency.join(MANIFEST),
1873            r#"
1874[package]
1875name = "coding-pack"
1876version = "0.1.0"
1877evals = ["evals/coding.toml"]
1878"#,
1879        )
1880        .unwrap();
1881        fs::write(
1882            dependency.join("evals/run.json"),
1883            serde_json::to_string_pretty(&serde_json::json!({
1884                "_type": "workflow_run",
1885                "id": "run_1",
1886                "workflow_id": "workflow_1",
1887                "status": "completed",
1888                "usage": {
1889                    "total_duration_ms": 12,
1890                    "total_cost": 0.01,
1891                    "input_tokens": 3,
1892                    "output_tokens": 4,
1893                    "call_count": 1,
1894                    "models": ["mock"]
1895                },
1896                "replay_fixture": {
1897                    "_type": "replay_fixture",
1898                    "expected_status": "completed"
1899                }
1900            }))
1901            .unwrap(),
1902        )
1903        .unwrap();
1904        fs::write(
1905            dependency.join("evals/coding.toml"),
1906            r#"
1907version = 1
1908id = "coding-pack"
1909trials = 2
1910
1911[package]
1912name = "coding-pack"
1913version = "0.1.0"
1914source = "path:test"
1915templates = ["templates/rubric.harn.prompt"]
1916
1917[metadata]
1918model = "mock-model"
1919commit = "commit-a"
1920
1921[[cases]]
1922id = "case-a"
1923run = "run.json"
1924rubrics = ["status"]
1925
1926[[rubrics]]
1927id = "status"
1928kind = "deterministic"
1929
1930[[rubrics.assertions]]
1931kind = "run-status"
1932expected = "completed"
1933"#,
1934        )
1935        .unwrap();
1936
1937        let helper = dependency_tmp.path().join("helper-lib");
1938        fs::create_dir_all(&helper).unwrap();
1939        fs::write(
1940            helper.join(MANIFEST),
1941            r#"
1942[package]
1943name = "helper-lib"
1944version = "0.1.0"
1945"#,
1946        )
1947        .unwrap();
1948
1949        let project_tmp = tempfile::tempdir().unwrap();
1950        let root = project_tmp.path();
1951        let workspace = TestWorkspace::new(root);
1952        fs::create_dir_all(root.join(".git")).unwrap();
1953        fs::write(
1954            root.join(MANIFEST),
1955            format!(
1956                r#"
1957[package]
1958name = "workspace"
1959version = "0.1.0"
1960
1961[dependencies]
1962coding-pack = {{ path = {} }}
1963helper-lib = {{ path = {} }}
1964"#,
1965                crate::format::toml_basic_string_literal(&dependency.display().to_string()),
1966                crate::format::toml_basic_string_literal(&helper.display().to_string())
1967            ),
1968        )
1969        .unwrap();
1970
1971        install_packages_in(workspace.env(), false, None, false).unwrap();
1972
1973        let paths = load_package_eval_pack_paths(Some(&root.join("src/main.harn"))).unwrap();
1974        assert_eq!(
1975            paths,
1976            vec![current_packages_dir(root)
1977                .join("coding-pack")
1978                .join("evals/coding.toml")]
1979        );
1980
1981        harn_vm::event_log::reset_active_event_log();
1982        let manifest = harn_vm::orchestration::load_eval_pack_manifest(&paths[0]).unwrap();
1983        let package = manifest.package.as_ref().expect("package descriptor");
1984        assert_eq!(package.name.as_deref(), Some("coding-pack"));
1985        assert_eq!(package.templates, vec!["templates/rubric.harn.prompt"]);
1986
1987        let report = harn_vm::orchestration::evaluate_eval_pack_manifest_resumable(
1988            &manifest,
1989            Some(serde_json::json!({
1990                "namespace": "installed-pack-evals",
1991                "suite": "coding-pack",
1992                "model": "mock-model",
1993                "commit": "commit-a",
1994                "branch": "main"
1995            })),
1996        )
1997        .unwrap();
1998        assert!(report.pass);
1999        assert_eq!(report.trial_count, 2);
2000        assert_eq!(report.run_state.ledger_rows_inserted, 2);
2001        assert_eq!(report.stats_rows.len(), 1);
2002        assert_eq!(report.stats_rows[0].trials, 2);
2003        assert!(!report.stats_rows[0].case_fingerprint.is_empty());
2004        assert_eq!(
2005            report.harness_config_fingerprint,
2006            report.stats_rows[0].harness_config_fingerprint
2007        );
2008
2009        let ledger = harn_vm::orchestration::eval_ledger_read_report(Some(serde_json::json!({
2010            "namespace": "installed-pack-evals",
2011            "suite": "coding-pack",
2012            "model": "mock-model",
2013            "commit": "commit-a"
2014        })))
2015        .unwrap();
2016        assert_eq!(ledger.rows.len(), 2);
2017        harn_vm::event_log::reset_active_event_log();
2018    }
2019    #[test]
2020    fn preflight_severity_parsing_accepts_synonyms() {
2021        assert_eq!(
2022            PreflightSeverity::from_opt(Some("warning")),
2023            PreflightSeverity::Warning
2024        );
2025        assert_eq!(
2026            PreflightSeverity::from_opt(Some("WARN")),
2027            PreflightSeverity::Warning
2028        );
2029        assert_eq!(
2030            PreflightSeverity::from_opt(Some("off")),
2031            PreflightSeverity::Off
2032        );
2033        assert_eq!(
2034            PreflightSeverity::from_opt(Some("allow")),
2035            PreflightSeverity::Off
2036        );
2037        assert_eq!(
2038            PreflightSeverity::from_opt(Some("error")),
2039            PreflightSeverity::Error
2040        );
2041        assert_eq!(PreflightSeverity::from_opt(None), PreflightSeverity::Error);
2042        // Unknown values fall back to the safe default (error).
2043        assert_eq!(
2044            PreflightSeverity::from_opt(Some("bogus")),
2045            PreflightSeverity::Error
2046        );
2047    }
2048
2049    #[test]
2050    fn load_check_config_walks_up_from_nested_file() {
2051        let tmp = tempfile::tempdir().unwrap();
2052        let root = tmp.path();
2053        // Mark root as project boundary so walk-up terminates here.
2054        std::fs::create_dir_all(root.join(".git")).unwrap();
2055        fs::write(
2056            root.join(MANIFEST),
2057            r#"
2058    [check]
2059    preflight_severity = "warning"
2060    preflight_allow = ["custom.scan", "runtime.*"]
2061    host_capabilities_path = "./schemas/host-caps.json"
2062
2063    [workspace]
2064    pipelines = ["pipelines", "scripts"]
2065    "#,
2066        )
2067        .unwrap();
2068        let nested = root.join("src").join("deep");
2069        std::fs::create_dir_all(&nested).unwrap();
2070        let harn_file = nested.join("pipeline.harn");
2071        fs::write(&harn_file, "pipeline main() {}\n").unwrap();
2072
2073        let cfg = load_check_config(Some(&harn_file));
2074        assert_eq!(cfg.preflight_severity.as_deref(), Some("warning"));
2075        assert_eq!(cfg.preflight_allow, vec!["custom.scan", "runtime.*"]);
2076        let caps_path = cfg.host_capabilities_path.expect("host caps path");
2077        assert!(
2078            caps_path.ends_with("schemas/host-caps.json")
2079                || caps_path.ends_with("schemas\\host-caps.json"),
2080            "unexpected absolutized path: {caps_path}"
2081        );
2082
2083        let (workspace, manifest_dir) =
2084            load_workspace_config(Some(&harn_file)).expect("workspace manifest");
2085        assert_eq!(workspace.pipelines, vec!["pipelines", "scripts"]);
2086        // Walk-up lands on the directory containing the harn.toml.
2087        assert_eq!(manifest_dir, root);
2088    }
2089
2090    #[test]
2091    fn toml_string_literal_escapes_all_basic_control_characters() {
2092        let literal = toml_string_literal("a\u{08}\t\n\u{0C}\r\"\\\u{07}z").unwrap();
2093        let parsed: toml::Value = toml::from_str(&format!("value = {literal}\n")).unwrap();
2094        assert_eq!(
2095            parsed.get("value").and_then(toml::Value::as_str),
2096            Some("a\u{08}\t\n\u{0C}\r\"\\\u{07}z")
2097        );
2098    }
2099
2100    #[test]
2101    fn orchestrator_drain_config_parses_defaults_and_overrides() {
2102        let default_manifest: Manifest = toml::from_str(
2103            r#"
2104    [package]
2105    name = "fixture"
2106    "#,
2107        )
2108        .unwrap();
2109        assert_eq!(default_manifest.orchestrator.drain.max_items, 1024);
2110        assert_eq!(default_manifest.orchestrator.drain.deadline_seconds, 30);
2111        assert_eq!(default_manifest.orchestrator.pumps.max_outstanding, 64);
2112
2113        let configured: Manifest = toml::from_str(
2114            r#"
2115    [package]
2116    name = "fixture"
2117
2118    [orchestrator]
2119    drain.max_items = 77
2120    drain.deadline_seconds = 12
2121    pumps.max_outstanding = 3
2122    "#,
2123        )
2124        .unwrap();
2125        assert_eq!(configured.orchestrator.drain.max_items, 77);
2126        assert_eq!(configured.orchestrator.drain.deadline_seconds, 12);
2127        assert_eq!(configured.orchestrator.pumps.max_outstanding, 3);
2128    }
2129
2130    #[test]
2131    fn load_check_config_stops_at_git_boundary() {
2132        let tmp = tempfile::tempdir().unwrap();
2133        // An ancestor harn.toml above .git must NOT be picked up.
2134        fs::write(
2135            tmp.path().join(MANIFEST),
2136            "[check]\npreflight_severity = \"off\"\n",
2137        )
2138        .unwrap();
2139        let project = tmp.path().join("project");
2140        std::fs::create_dir_all(project.join(".git")).unwrap();
2141        let inner = project.join("src");
2142        std::fs::create_dir_all(&inner).unwrap();
2143        let harn_file = inner.join("main.harn");
2144        fs::write(&harn_file, "pipeline main() {}\n").unwrap();
2145        let cfg = load_check_config(Some(&harn_file));
2146        assert!(
2147            cfg.preflight_severity.is_none(),
2148            "must not inherit harn.toml from outside the .git boundary"
2149        );
2150    }
2151}