Skip to main content

harness_lock_core/
meta.rs

1//! `hlock meta`: the locked agent hlock uses for its own judgment work
2//! (`specs/meta-harness.md`).
3//!
4//! The meta harness is pinned in `harness.meta.lock` (an ordinary pinned lock), and
5//! optionally configured in `harness.meta.toml`. A *job* is a prompt rendered from
6//! project state ([`render`]) plus a check hlock runs itself afterwards
7//! ([`verify`]). [`prepare`] builds the command that runs a job in the meta harness,
8//! isolated the same way `hlock launch` is.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::capture::{read_config, render_config, PinOutcome};
17use crate::error::{Error, Result};
18use crate::launch::{self, LaunchOptions};
19use crate::localize::{choose_local, subst_value, LocalChoice};
20use crate::lockfile::Lockfile;
21use crate::manifest::Manifest;
22use crate::profiles::{probe_harness, probe_runtime, Ctx, HarnessProfile, LocalProvider, Registry};
23use crate::sync::{self, set_pointer, SyncOptions};
24use crate::tasks::{EvalPack, PackFile, PLACEHOLDER};
25
26/// The meta harness's optional manifest.
27pub const META_MANIFEST_PATH: &str = "harness.meta.toml";
28/// The pinned meta harness.
29pub const META_LOCK_PATH: &str = "harness.meta.lock";
30/// Where run records go, relative to the project.
31pub const RUNS_DIR: &str = ".hlock/meta/runs";
32/// Set in the meta harness's environment: the variables isolation changed, as a JSON
33/// object of original values (`null` for unset). hlock processes put them back.
34pub const RESTORE_ENV: &str = "HLOCK_META_RESTORE";
35
36/// Stands in for the prompt while the command is built, before the prompt is rendered.
37const PROMPT_TOKEN: &str = "\u{1}hlock-meta-prompt\u{1}";
38
39/// The agent that ships inside hlock (see [`crate::agent`]): the default meta harness.
40pub const BUILTIN: &str = "builtin";
41/// Harnesses the meta harness can be, most preferred first: the built-in agent, then
42/// open-source harnesses the user installs.
43pub const META_HARNESSES: &[&str] = &[BUILTIN, "pi", "codex", "goose", "opencode", "hermes"];
44/// The model `hlock meta init` picks for each API provider.
45pub const DEFAULT_MODELS: &[(&str, &str)] = &[("anthropic", "claude-sonnet-5-5"), ("openai", "gpt-5.5"), ("google", "gemini-3.5-flash")];
46
47/// Built-in jobs: name and description.
48pub const BUILTIN_JOBS: [(&str, &str); 3] = [
49    ("install", "Install what the project's lock still needs on this machine"),
50    ("bootstrap-evals", "Fill in an eval pack with real tasks from this project"),
51    ("update", "Update the lock's skills, MCP servers and plugins"),
52];
53
54// --------------------------------------------------------------------------- //
55// harness.meta.toml
56// --------------------------------------------------------------------------- //
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59#[serde(default)]
60pub struct JobSpec {
61    pub description: Option<String>,
62    /// The prompt of a custom job; `{lock}`, `{manifest}`, `{project}` and `{meta_lock}` are substituted.
63    pub prompt: Option<String>,
64    /// A shell command run in the project after the agent exits; exit 0 means verified.
65    pub verify: Option<String>,
66    /// Text appended to the job's prompt (built-in or custom).
67    pub instructions: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71#[serde(default)]
72pub struct RunDefaults {
73    /// `interactive` (default) or `headless`.
74    pub mode: Option<String>,
75}
76
77/// `[model]`: what the meta harness talks to. An API provider (`provider`, with its key
78/// in the `key-env` variable), or a local runtime (`runtime`) serving open weights.
79#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
80#[serde(default, rename_all = "kebab-case")]
81pub struct MetaModel {
82    pub provider: Option<String>,
83    pub name: Option<String>,
84    pub key_env: Option<String>,
85    pub runtime: Option<String>,
86    pub base_url: Option<String>,
87}
88
89impl MetaModel {
90    pub fn is_local(&self) -> bool {
91        self.runtime.is_some()
92    }
93}
94
95/// `[agent]`: what the built-in agent may do.
96#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
97#[serde(default, rename_all = "kebab-case")]
98pub struct AgentSpec {
99    /// Command prefixes that run without asking (default: `hlock` and read-only git).
100    pub allow: Option<Vec<String>>,
101    pub max_steps: Option<u32>,
102    pub timeout_seconds: Option<u64>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
106#[serde(default)]
107pub struct MetaManifest {
108    pub target: BTreeMap<String, Value>,
109    pub model: MetaModel,
110    pub agent: AgentSpec,
111    pub run: RunDefaults,
112    pub jobs: BTreeMap<String, JobSpec>,
113}
114
115impl MetaManifest {
116    pub fn load(path: &Path) -> Result<MetaManifest> {
117        let text = std::fs::read_to_string(path).map_err(|e| Error::Manifest(format!("cannot read {}: {e}", path.display())))?;
118        MetaManifest::from_str(&text)
119    }
120
121    pub fn from_str(text: &str) -> Result<MetaManifest> {
122        let m: MetaManifest = toml::from_str(text).map_err(|e| Error::Manifest(format!("invalid {META_MANIFEST_PATH}: {e}")))?;
123        if let Some(mode) = &m.run.mode {
124            if !matches!(mode.as_str(), "interactive" | "headless") {
125                return Err(Error::Manifest(format!("{META_MANIFEST_PATH}: [run] mode must be \"interactive\" or \"headless\", not {mode:?}")));
126            }
127        }
128        if m.model.name.is_some() && m.model.provider.is_none() && m.model.runtime.is_none() {
129            return Err(Error::Manifest(format!("{META_MANIFEST_PATH}: [model] needs a `provider` (an API provider) or a `runtime` (a local one)")));
130        }
131        for (name, spec) in &m.jobs {
132            let builtin = BUILTIN_JOBS.iter().any(|(b, _)| b == name);
133            if builtin && (spec.prompt.is_some() || spec.verify.is_some()) {
134                return Err(Error::Manifest(format!(
135                    "{META_MANIFEST_PATH}: [jobs.{name}] is a built-in job; it takes `instructions` only, not `prompt` or `verify`"
136                )));
137            }
138            if !builtin && spec.prompt.as_deref().map_or(true, |p| p.trim().is_empty()) {
139                return Err(Error::Manifest(format!("{META_MANIFEST_PATH}: [jobs.{name}] needs a `prompt`")));
140            }
141        }
142        Ok(m)
143    }
144
145    /// `[target] harness`: the meta harness.
146    pub fn target_harness(&self) -> Option<String> {
147        self.target.get("harness").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string)
148    }
149
150    pub fn headless(&self) -> bool {
151        self.run.mode.as_deref() == Some("headless")
152    }
153}
154
155// --------------------------------------------------------------------------- //
156// Choosing and configuring the meta harness
157// --------------------------------------------------------------------------- //
158
159/// Whether `name` names the built-in agent.
160pub fn is_builtin(name: &str) -> bool {
161    matches!(name, BUILTIN | "hlock" | "hlock-agent")
162}
163
164/// A profile standing in for the built-in agent, so choosing a model works the same way.
165pub fn builtin_profile() -> HarnessProfile {
166    HarnessProfile {
167        id: BUILTIN.into(),
168        name: "built-in agent".into(),
169        kind: "builtin".into(),
170        open_source: Some(true),
171        local_models: Some(true),
172        api_keys: crate::agent::PROVIDERS.iter().map(|(p, _, k)| crate::profiles::ApiKey { provider: p.to_string(), env: k.to_string() }).collect(),
173        origin: "builtin".into(),
174        ..Default::default()
175    }
176}
177
178/// Why a harness can't be the meta harness (`None` when it can).
179pub fn ineligible(p: &HarnessProfile) -> Option<String> {
180    if p.kind == "custom" || p.kind == "builtin" {
181        return None;
182    }
183    if p.kind != "cli" {
184        return Some(format!("{} is a framework; declare a project harness built on it under [harnesses.<id>] in harness.toml", p.name));
185    }
186    if p.open_source != Some(true) {
187        return Some(format!("{} isn't open source; the meta harness must be one of: {}", p.name, META_HARNESSES.join(", ")));
188    }
189    if p.config_root.env.is_none() {
190        return Some(format!("{}'s config root can't be relocated, so it can't have a config of its own", p.name));
191    }
192    if p.launch.prompt.is_empty() && p.launch.interactive.is_empty() {
193        return Some(format!("the {} profile doesn't say how to give it a prompt ([launch] prompt or interactive)", p.name));
194    }
195    None
196}
197
198/// The meta harness's profile: the built-in agent's, or an eligible registry profile.
199fn meta_profile<'a>(reg: &'a Registry, name: &str, builtin: &'a HarnessProfile) -> Result<&'a HarnessProfile> {
200    if is_builtin(name) {
201        Ok(builtin)
202    } else {
203        eligible_harness(reg, name)
204    }
205}
206
207fn eligible_harness<'a>(reg: &'a Registry, name: &str) -> Result<&'a HarnessProfile> {
208    let p = reg.require_harness(name)?;
209    match ineligible(p) {
210        Some(why) => Err(Error::Profile(why)),
211        None => Ok(p),
212    }
213}
214
215/// What `hlock meta init` asks for; everything is optional.
216#[derive(Debug, Clone, Default)]
217pub struct InitOptions {
218    pub harness: Option<String>,
219    /// An API provider (`anthropic`, `openai`, …) or `local`.
220    pub provider: Option<String>,
221    pub model: Option<String>,
222    /// A local runtime (implies `local`).
223    pub runtime: Option<String>,
224    pub memory_gb: Option<f64>,
225    /// Where the model's API lives, overriding the provider's or runtime's default.
226    pub base_url: Option<String>,
227}
228
229/// The meta harness and model `hlock meta init` settled on, and what's still missing.
230#[derive(Debug, Clone, Serialize, Default)]
231pub struct Choice {
232    pub harness: String,
233    pub harness_name: String,
234    pub why: String,
235    pub installed: bool,
236    /// The command that installs the harness, when it isn't installed.
237    pub install: Option<String>,
238    pub model: MetaModel,
239    pub model_why: String,
240    /// The API key variable is set (always true for local models).
241    pub key_set: bool,
242    pub local: Option<LocalChoice>,
243}
244
245/// Choose the meta harness and its model from the options, the API keys in the
246/// environment, and the local runtimes installed.
247pub fn choose(reg: &Registry, ctx: &Ctx, opts: &InitOptions) -> Result<Choice> {
248    let builtin = builtin_profile();
249    let (profile, why) = match &opts.harness {
250        Some(h) => (meta_profile(reg, h, &builtin)?, "chosen with --harness".to_string()),
251        None => (&builtin, "ships with hlock; nothing to install".to_string()),
252    };
253    let installed = profile.kind != "cli" || probe_harness(profile, ctx).installed;
254    let install = (!installed)
255        .then(|| {
256            profile.install.iter().find_map(|m| {
257                let tool = match m.method.as_str() {
258                    "npm" => "npm",
259                    "brew" | "brew-cask" => "brew",
260                    "uv-tool" => "uv",
261                    "pip" => "python3",
262                    "script" => "sh",
263                    _ => return None,
264                };
265                ctx.which(tool)?;
266                sync::install_command(&m.method, m.package.as_deref(), None, m.command.as_deref()).map(|argv| sync::shell_join(&argv))
267            })
268        })
269        .flatten();
270    let key_for = |provider: &str| profile.api_keys.iter().find(|k| k.provider == provider).map(|k| k.env.clone());
271    let can_local = matches!(profile.kind.as_str(), "custom" | "builtin") || (profile.local_models == Some(true) && !profile.local_provider.is_empty());
272    let want_local = opts.runtime.is_some() || opts.provider.as_deref() == Some("local");
273    let local_installed = || crate::localize::DEFAULT_RUNTIMES.iter().filter_map(|r| reg.runtime(r)).any(|r| probe_runtime(r, ctx).installed);
274    let api = |provider: &str, why: String| -> Result<(MetaModel, String, bool)> {
275        let key = key_for(provider).ok_or_else(|| {
276            let known: Vec<&str> = profile.api_keys.iter().map(|k| k.provider.as_str()).collect();
277            Error::Profile(format!("{} doesn't support the API provider {provider:?} (it supports: {})", profile.name, if known.is_empty() { "none".to_string() } else { known.join(", ") }))
278        })?;
279        let name = opts.model.clone().or_else(|| DEFAULT_MODELS.iter().find(|(p, _)| *p == provider).map(|(_, m)| m.to_string())).ok_or_else(|| {
280            Error::Profile(format!("there's no default {provider} model; pass --model"))
281        })?;
282        let set = ctx.var(&key).is_some();
283        Ok((MetaModel { provider: Some(provider.into()), name: Some(name), key_env: Some(key), ..Default::default() }, why, set))
284    };
285    let (model, model_why, key_set, local) = if want_local || (opts.provider.is_none() && !profile.api_keys.iter().any(|k| ctx.var(&k.env).is_some()) && can_local && local_installed()) {
286        if !can_local {
287            return Err(Error::Profile(format!("{} can't use a local model endpoint", profile.name)));
288        }
289        let lc = choose_local(reg, ctx, opts.runtime.as_deref(), opts.model.as_deref(), opts.memory_gb)?;
290        let why = if want_local { "chosen with --local".to_string() } else { format!("no API key is set and {} is installed", lc.runtime_name) };
291        let m = MetaModel { runtime: Some(lc.runtime.clone()), name: Some(lc.model.clone()), ..Default::default() };
292        (m, why, true, Some(lc))
293    } else if let Some(p) = &opts.provider {
294        let (m, w, k) = api(p, "chosen with --provider".into())?;
295        (m, w, k, None)
296    } else if let Some(k) = profile.api_keys.iter().find(|k| ctx.var(&k.env).is_some()) {
297        let (m, w, set) = api(&k.provider, format!("{} is set", k.env))?;
298        (m, w, set, None)
299    } else if let Some(k) = profile.api_keys.first() {
300        let (m, w, set) = api(&k.provider, format!("no API key or local runtime found; {} is {}'s first provider", k.provider, profile.name))?;
301        (m, w, set, None)
302    } else if can_local {
303        let lc = choose_local(reg, ctx, None, opts.model.as_deref(), opts.memory_gb)?;
304        let m = MetaModel { runtime: Some(lc.runtime.clone()), name: Some(lc.model.clone()), ..Default::default() };
305        (m, format!("{} only talks to local models", profile.name), true, Some(lc))
306    } else {
307        (MetaModel::default(), format!("{} uses its own model settings", profile.name), true, None)
308    };
309    let mut model = model;
310    if opts.base_url.is_some() {
311        model.base_url = opts.base_url.clone();
312    }
313    Ok(Choice { harness: profile.id.clone(), harness_name: profile.name.clone(), why, installed, install, model, model_why, key_set, local })
314}
315
316/// A starter `harness.meta.toml` for a choice.
317pub fn manifest_text(c: &Choice) -> String {
318    // `key = value` lines with their comments lined up
319    let line = |kv: String, comment: &str| if comment.is_empty() { format!("{kv}\n") } else { format!("{kv:<30}# {comment}\n") };
320    let mut s = String::from("# hlock meta manifest: the agent hlock uses for its own work (hlock meta --help).\n");
321    s.push_str("[target]\n");
322    s.push_str(&line(format!("harness = \"{}\"", c.harness), &format!("builtin (ships with hlock), or one you install: {}", META_HARNESSES[1..].join(", "))));
323    let m = &c.model;
324    if m.name.is_some() || m.runtime.is_some() {
325        s.push_str("\n[model]\n");
326        if let Some(r) = &m.runtime {
327            s.push_str(&line(format!("runtime = \"{r}\""), "a local runtime serving open weights (hlock runtime list)"));
328        }
329        if let Some(p) = &m.provider {
330            s.push_str(&line(format!("provider = \"{p}\""), ""));
331        }
332        if let Some(n) = &m.name {
333            s.push_str(&line(format!("name = \"{n}\""), ""));
334        }
335        if let Some(k) = &m.key_env {
336            s.push_str(&line(format!("key-env = \"{k}\""), "the variable holding the API key; the key is never written"));
337        }
338        if let Some(u) = &m.base_url {
339            s.push_str(&line(format!("base-url = \"{u}\""), ""));
340        }
341    }
342    if is_builtin(&c.harness) {
343        s.push_str("\n[agent]\n# commands that run without asking; others need your OK, and unattended runs refuse them\n");
344        s.push_str(&line(format!("allow = [{}]", crate::agent::DEFAULT_ALLOW.iter().map(|a| format!("\"{a}\"")).collect::<Vec<_>>().join(", ")), ""));
345    }
346    s.push_str("\n[run]\n");
347    s.push_str(&line("mode = \"interactive\"".into(), "or \"headless\""));
348    s.push_str("\n# Extra instructions for a built-in job, or a custom job:\n# [jobs.install]\n# instructions = \"Prefer Homebrew. Never use sudo.\"\n");
349    s
350}
351
352/// The meta harness's own environment: a context whose config root is its dedicated
353/// directory (the isolated root `hlock launch` uses for the meta lock), the variables
354/// that point there, and the root.
355pub fn meta_env(profile: &HarnessProfile, ctx: &Ctx, meta_lock_path: &Path) -> (Ctx, BTreeMap<String, String>, PathBuf) {
356    let sopts = SyncOptions { prefix: Some(launch::env_prefix(meta_lock_path, ctx)), ..Default::default() };
357    let (t, _, iso) = sync::target_ctx(profile, ctx, &sopts);
358    let root = profile.config_root(&t);
359    (t, iso, root)
360}
361
362/// Write `[model]` into the meta harness's own config (never the user's).
363pub fn configure(profile: &HarnessProfile, model: &MetaModel, tctx: &Ctx) -> Result<Vec<PathBuf>> {
364    if profile.kind != "cli" || (model.name.is_none() && model.runtime.is_none()) {
365        return Ok(Vec::new());
366    }
367    let name = model.name.clone().unwrap_or_default();
368    let (templates, vars): (&[LocalProvider], Vec<(&str, String)>) = if let Some(rt) = &model.runtime {
369        let base = model.base_url.clone().unwrap_or_else(|| "http://localhost:11434/v1".into());
370        let host = base.trim_end_matches('/').trim_end_matches("/v1").to_string();
371        (&profile.local_provider, vec![("provider", rt.clone()), ("model", name), ("base_url", base), ("host", host)])
372    } else {
373        (&profile.api_provider, vec![("provider", model.provider.clone().unwrap_or_default()), ("model", name)])
374    };
375    if templates.is_empty() {
376        return Err(Error::Profile(format!(
377            "the {} profile doesn't say how to set {} model; configure it in `hlock meta launch`, then run `hlock meta pin`",
378            profile.name,
379            if model.is_local() { "a local" } else { "an API" }
380        )));
381    }
382    let vars: Vec<(&str, &str)> = vars.iter().map(|(k, v)| (*k, v.as_str())).collect();
383    let mut per_file: BTreeMap<String, Vec<(String, Value)>> = BTreeMap::new();
384    for lp in templates {
385        for (ptr, v) in &lp.set {
386            let ptr = subst_value(&Value::String(ptr.clone()), &vars).as_str().unwrap_or_default().to_string();
387            per_file.entry(lp.file.clone()).or_default().push((ptr, subst_value(v, &vars)));
388        }
389    }
390    let mut written = Vec::new();
391    for (file, sets) in per_file {
392        let spec = profile.files.get(&file).ok_or_else(|| Error::Profile(format!("the {} profile has no file {file:?}", profile.id)))?;
393        let path = profile.file_path(&file, tctx).ok_or_else(|| Error::Profile(format!("the {} profile has no file {file:?}", profile.id)))?;
394        let mut body = read_config(&path, &spec.format)?.unwrap_or_else(|| Value::Object(Default::default()));
395        for (ptr, v) in sets {
396            set_pointer(&mut body, &ptr, v);
397        }
398        if let Some(parent) = path.parent() {
399            std::fs::create_dir_all(parent)?;
400        }
401        std::fs::write(&path, render_config(&body, &spec.format).map_err(Error::Profile)?)?;
402        written.push(path);
403    }
404    Ok(written)
405}
406
407/// The base URL `[model]` points at for a local runtime.
408fn local_base_url(reg: &Registry, model: &MetaModel) -> Option<String> {
409    let rt = reg.runtime(model.runtime.as_deref()?)?;
410    Some(model.base_url.clone().unwrap_or_else(|| format!("http://localhost:{}/v1", rt.ports.first().copied().unwrap_or(8080))))
411}
412
413/// `hlock meta pin`: write `[model]` into the meta harness's own config, then pin that
414/// config into `out`.
415pub fn pin(reg: &Registry, ctx: &Ctx, meta: &MetaManifest, out: &Path, project: Option<&str>, vendor: bool, force: bool) -> Result<PinOutcome> {
416    let harness = meta.target_harness().ok_or_else(|| Error::Manifest(format!("{META_MANIFEST_PATH} has no [target] harness; run `hlock meta init`")))?;
417    if is_builtin(&harness) {
418        return pin_builtin(reg, meta, out, project);
419    }
420    let profile = eligible_harness(reg, &harness)?;
421    let (tctx, _, root) = meta_env(profile, ctx, out);
422    std::fs::create_dir_all(&root)?;
423    let mut model = meta.model.clone();
424    if model.is_local() {
425        model.base_url = local_base_url(reg, &model);
426    }
427    configure(profile, &model, &tctx)?;
428    crate::capture::pin(reg, &tctx, Some(&profile.id), None, project, out, vendor, force)
429}
430
431/// The lock for the built-in agent: hlock's version, the model, and what it may do.
432fn builtin_lock(reg: &Registry, meta: &MetaManifest, project: Option<&str>) -> Result<Lockfile> {
433    let m = &meta.model;
434    let model = m.name.clone().ok_or_else(|| Error::Manifest(format!("{META_MANIFEST_PATH}: the built-in agent needs a [model] name")))?;
435    let mut llm = crate::lockfile::LLM { role: "default".into(), model, resolved_from: Some(format!("{META_MANIFEST_PATH}#/model")), ..Default::default() };
436    if let Some(rt) = &m.runtime {
437        llm.provider = rt.clone();
438        llm.runtime = Some(rt.clone());
439        llm.endpoint = Some(local_base_url(reg, m).ok_or_else(|| Error::Profile(format!("unknown runtime {rt:?} (see hlock runtime list)")))?);
440    } else {
441        let provider = m.provider.clone().ok_or_else(|| Error::Manifest(format!("{META_MANIFEST_PATH}: [model] needs a provider or a runtime")))?;
442        if m.base_url.is_none() && crate::agent::provider_base_url(&provider).is_none() {
443            return Err(Error::Manifest(format!(
444                "the built-in agent doesn't know where {provider:?} is; set [model] base-url to its OpenAI-compatible endpoint"
445            )));
446        }
447        llm.endpoint = m.base_url.clone();
448        if let Some(k) = m.key_env.clone().or_else(|| crate::agent::provider_key_env(&provider).map(str::to_string)) {
449            llm.params.insert("key-env".into(), Value::String(k));
450        }
451        llm.provider = provider;
452    }
453    let version = env!("CARGO_PKG_VERSION").to_string();
454    let mut lf = Lockfile { kind: Some("pin".into()), llm: vec![llm], ..Default::default() };
455    lf.stack.insert("name".into(), Value::String(project.unwrap_or(BUILTIN).to_string()));
456    lf.stack.insert("harness".into(), Value::String(BUILTIN.into()));
457    lf.stack.insert("selection".into(), Value::String("pin".into()));
458    lf.harness = crate::lockfile::Harness { name: BUILTIN.into(), version: Some(version.clone()), source: Some(format!("hlock@{version}")), ..Default::default() };
459    lf.policy.tool_allowlist = meta.agent.allow.clone().unwrap_or_else(|| crate::agent::DEFAULT_ALLOW.iter().map(|s| s.to_string()).collect());
460    lf.policy.max_steps = meta.agent.max_steps.unwrap_or(crate::agent::DEFAULT_MAX_STEPS);
461    lf.policy.tool_timeout_seconds = Some(meta.agent.timeout_seconds.unwrap_or(crate::agent::DEFAULT_TIMEOUT_SECONDS));
462    lf.provenance.backend = "pin".into();
463    Ok(lf)
464}
465
466fn pin_builtin(reg: &Registry, meta: &MetaManifest, out: &Path, project: Option<&str>) -> Result<PinOutcome> {
467    let lock = builtin_lock(reg, meta, project)?;
468    let previous = Lockfile::load(out).ok().filter(Lockfile::is_pin);
469    let mut diff = previous.as_ref().map(|p| crate::capture::diff_locks(p, &lock)).unwrap_or_default();
470    if let Some(p) = &previous {
471        diff.extend(policy_diff(&p.policy, &lock.policy));
472    }
473    lock.save(out)?;
474    Ok(PinOutcome {
475        lock,
476        path: out.to_path_buf(),
477        selection: crate::profiles::Selection { id: BUILTIN.into(), reason: "ships with hlock".into() },
478        diff,
479        previous: previous.is_some(),
480        vendored: Vec::new(),
481        warnings: Vec::new(),
482    })
483}
484
485/// What the built-in agent may do, as drift lines.
486fn policy_diff(old: &crate::lockfile::Policy, new: &crate::lockfile::Policy) -> Vec<String> {
487    let mut out = Vec::new();
488    if old.tool_allowlist != new.tool_allowlist {
489        out.push(format!("~ agent allow: {} → {}", old.tool_allowlist.join(", "), new.tool_allowlist.join(", ")));
490    }
491    if old.max_steps != new.max_steps {
492        out.push(format!("~ agent max-steps: {} → {}", old.max_steps, new.max_steps));
493    }
494    if old.tool_timeout_seconds != new.tool_timeout_seconds {
495        out.push(format!("~ agent timeout-seconds: {:?} → {:?}", old.tool_timeout_seconds, new.tool_timeout_seconds));
496    }
497    out
498}
499
500/// `hlock meta check`: what drifted between `harness.meta.toml`, the meta lock, and the
501/// meta harness's own config. Empty when everything matches.
502pub fn check(reg: &Registry, ctx: &Ctx, meta: Option<&MetaManifest>, lock: &Lockfile, lock_path: &Path) -> Result<Vec<String>> {
503    let mut out = Vec::new();
504    if is_builtin(&lock.harness.name) {
505        let want = env!("CARGO_PKG_VERSION");
506        if lock.harness.version.as_deref() != Some(want) {
507            out.push(format!("~ harness builtin: the lock pins hlock {}, this is hlock {want}", lock.harness.version.as_deref().unwrap_or("?")));
508        }
509        if let Some(m) = meta {
510            if m.target_harness().is_some_and(|t| !is_builtin(&t)) {
511                out.push(format!("~ harness: {META_MANIFEST_PATH} targets {}, the lock pins the built-in agent", m.target_harness().unwrap_or_default()));
512            } else {
513                let fresh = builtin_lock(reg, m, lock.stack.get("name").and_then(Value::as_str))?;
514                out.extend(crate::capture::diff_locks(lock, &fresh));
515                out.extend(policy_diff(&lock.policy, &fresh.policy));
516            }
517        }
518        return Ok(out);
519    }
520    let profile = reg.require_harness(&lock.harness.name)?;
521    if let Some(why) = ineligible(profile) {
522        out.push(format!("! {why}"));
523    }
524    if let Some(m) = meta {
525        if let Some(t) = m.target_harness() {
526            if reg.harness(&t).map(|p| p.id.clone()).unwrap_or(t.clone()) != profile.id {
527                out.push(format!("~ harness: {META_MANIFEST_PATH} targets {t}, the lock pins {}", profile.id));
528            }
529        }
530        if let Some(want) = &m.model.name {
531            let have = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first()).map(|l| l.model.clone());
532            if have.as_deref() != Some(want.as_str()) {
533                out.push(format!("~ model: {META_MANIFEST_PATH} sets {want}, the lock pins {}", have.unwrap_or_else(|| "none".into())));
534            }
535        }
536    }
537    let (tctx, _, _) = meta_env(profile, ctx, lock_path);
538    out.extend(crate::capture::check_pin(lock, reg, &tctx)?);
539    Ok(out)
540}
541
542/// Before a run: what would stop the meta harness from reaching its model.
543fn preflight(reg: &Registry, ctx: &Ctx, meta: Option<&MetaManifest>, profile: &HarnessProfile, notes: &mut Vec<String>) -> Result<()> {
544    let Some(m) = meta.map(|m| &m.model) else { return Ok(()) };
545    if let Some(key) = &m.key_env {
546        if ctx.var(key).is_none() {
547            return Err(Error::Profile(format!(
548                "{key} isn't set; {} reads its {} API key from it. Export it and run the job again",
549                profile.name,
550                m.provider.as_deref().unwrap_or("provider")
551            )));
552        }
553    }
554    if let Some(rt) = m.runtime.as_deref().and_then(|r| reg.runtime(r)) {
555        if !probe_runtime(rt, ctx).installed {
556            return Err(Error::Profile(format!("{} isn't installed, so the meta harness has no model to talk to; run `hlock meta sync --install`", rt.name)));
557        }
558        if let Some(name) = &m.name {
559            if !crate::capture::locate_model(rt, name, ctx).present {
560                let pull = rt.pull.as_ref().map(|p| format!(": `{}`", p.replace("{model}", name))).unwrap_or_default();
561                notes.push(format!("{name} isn't on disk yet{pull}"));
562            }
563        }
564    }
565    Ok(())
566}
567
568// --------------------------------------------------------------------------- //
569// Jobs
570// --------------------------------------------------------------------------- //
571
572#[derive(Debug, Clone, Serialize)]
573pub struct JobInfo {
574    pub name: String,
575    pub description: String,
576    pub builtin: bool,
577    /// Whether hlock can check the result (always for built-ins; custom jobs with `verify`).
578    pub verifies: bool,
579}
580
581/// The built-in jobs, then the custom ones from `harness.meta.toml`.
582pub fn jobs(meta: Option<&MetaManifest>) -> Vec<JobInfo> {
583    let mut out: Vec<JobInfo> = BUILTIN_JOBS
584        .iter()
585        .map(|(n, d)| JobInfo { name: n.to_string(), description: d.to_string(), builtin: true, verifies: true })
586        .collect();
587    if let Some(m) = meta {
588        for (name, spec) in &m.jobs {
589            if BUILTIN_JOBS.iter().any(|(b, _)| b == name) {
590                continue;
591            }
592            out.push(JobInfo {
593                name: name.clone(),
594                description: spec.description.clone().unwrap_or_default(),
595                builtin: false,
596                verifies: spec.verify.is_some(),
597            });
598        }
599    }
600    out
601}
602
603fn job_spec<'a>(name: &str, meta: Option<&'a MetaManifest>) -> Result<Option<&'a JobSpec>> {
604    let builtin = BUILTIN_JOBS.iter().any(|(b, _)| *b == name);
605    let spec = meta.and_then(|m| m.jobs.get(name));
606    if !builtin && spec.is_none() {
607        let known: Vec<String> = jobs(meta).into_iter().map(|j| j.name).collect();
608        return Err(Error::Profile(format!("unknown meta job {name:?}; known jobs: {}", known.join(", "))));
609    }
610    Ok(spec)
611}
612
613/// Everything a job reads from the project.
614#[derive(Debug, Clone, Default)]
615pub struct JobInput {
616    /// The project directory (where the project's lock lives).
617    pub project: PathBuf,
618    /// The project's lock.
619    pub lock_path: PathBuf,
620    pub manifest: Option<Manifest>,
621    pub manifest_path: Option<PathBuf>,
622    pub meta_lock_path: PathBuf,
623    /// `bootstrap-evals`: the pack directory (default: the manifest's first eval pack, else `./evals`).
624    pub out: Option<PathBuf>,
625    /// `bootstrap-evals`: how many tasks to aim for.
626    pub count: usize,
627    /// `bootstrap-evals`: the Agent Skill that says how to fill in the pack's template, if one was written.
628    pub guide: Option<PathBuf>,
629    /// The user's extra instructions.
630    pub instructions: String,
631}
632
633impl JobInput {
634    /// A path for prompts and messages: relative to the project when it's inside it.
635    fn show(&self, p: &Path) -> String {
636        p.strip_prefix(&self.project).map(|r| r.to_string_lossy().into_owned()).ok().filter(|s| !s.is_empty()).unwrap_or_else(|| p.to_string_lossy().into_owned())
637    }
638
639    /// The `bootstrap-evals` directory.
640    pub fn pack_dir(&self) -> PathBuf {
641        if let Some(o) = &self.out {
642            return if o.is_absolute() { o.clone() } else { self.project.join(o) };
643        }
644        if let Some(m) = &self.manifest {
645            if let Some(first) = m.eval_packs().first() {
646                let p = PathBuf::from(first);
647                return if p.is_absolute() { p } else { m.root().join(p) };
648            }
649        }
650        self.project.join("evals")
651    }
652
653    fn load_lock(&self) -> Result<Lockfile> {
654        if !self.lock_path.exists() {
655            return Err(Error::Lockfile(format!(
656                "cannot read {}: no such file; write one with `hlock pin` or `hlock resolve`",
657                self.show(&self.lock_path)
658            )));
659        }
660        Lockfile::load(&self.lock_path)
661    }
662}
663
664// --------------------------------------------------------------------------- //
665// Prompts
666// --------------------------------------------------------------------------- //
667
668/// The job's prompt. `env_note` describes the meta harness's isolation (empty when it
669/// runs in place).
670pub fn render(job: &str, input: &JobInput, meta: Option<&MetaManifest>, reg: &Registry, ctx: &Ctx, env_note: &str) -> Result<String> {
671    let spec = job_spec(job, meta)?;
672    let (goal, check) = match job {
673        "install" => install_goal(input, reg, ctx)?,
674        "bootstrap-evals" => pack_goal(input),
675        "update" => update_goal(input, reg, ctx)?,
676        _ => {
677            let spec = spec.expect("custom jobs have a spec");
678            let body = substitute(spec.prompt.as_deref().unwrap_or_default(), input);
679            let check = spec.verify.is_some().then(|| format!("hlock meta verify {job}"));
680            (body.trim_end().to_string(), check)
681        }
682    };
683    let mut s = String::from(
684        "You are hlock's meta harness: the agent hlock (harness-lock) calls for work that needs judgment. \
685         When you finish, hlock checks the result itself, so do the job rather than describe it.\n\n",
686    );
687    s.push_str(&format!("Project: {}\n\n## Job: {job}\n\n{goal}\n\n", input.project.display()));
688    s.push_str("## Rules\n\n");
689    s.push_str("- Stay inside the job. Don't change files or settings it doesn't need.\n");
690    s.push_str("- Ask the user before installing system software or downloading large files, and say how big they are.\n");
691    s.push_str("- Never print, log, or commit secret values. Ask the user to set secrets in their shell; don't set them yourself.\n");
692    if !env_note.is_empty() {
693        s.push_str(&format!("- {env_note}\n"));
694    }
695    if let Some(c) = check {
696        s.push_str(&format!("\n## Check your work\n\nRun `{c}` and fix what it reports before you finish.\n"));
697    }
698    let extra: Vec<&str> = [spec.and_then(|j| j.instructions.as_deref()), Some(input.instructions.as_str())]
699        .into_iter()
700        .flatten()
701        .map(str::trim)
702        .filter(|t| !t.is_empty())
703        .collect();
704    if !extra.is_empty() {
705        s.push_str(&format!("\n## Instructions from the user\n\n{}\n", extra.join("\n\n")));
706    }
707    Ok(s)
708}
709
710fn substitute(template: &str, input: &JobInput) -> String {
711    let manifest = input.manifest_path.as_deref().map(|p| input.show(p)).unwrap_or_else(|| crate::MANIFEST_PATH.into());
712    template
713        .replace("{lock}", &input.show(&input.lock_path))
714        .replace("{meta_lock}", &input.show(&input.meta_lock_path))
715        .replace("{manifest}", &manifest)
716        .replace("{project}", &input.project.to_string_lossy())
717}
718
719fn install_goal(input: &JobInput, reg: &Registry, ctx: &Ctx) -> Result<(String, Option<String>)> {
720    let lock = input.load_lock()?;
721    let plan = sync::plan(&lock, &input.lock_path, reg, ctx, &SyncOptions::default())?;
722    let lock_name = input.show(&input.lock_path);
723    let mut s = format!(
724        "Make this machine match `{lock_name}` for {} {}: install what's missing, so `hlock sync` has nothing left that needs a person.\n\n",
725        plan.harness_name,
726        plan.version.as_deref().unwrap_or("")
727    );
728    let pending: Vec<&sync::Step> = plan.steps.iter().filter(|st| st.status != "satisfied" && !st.optional).collect();
729    let (secrets, steps): (Vec<&sync::Step>, Vec<&sync::Step>) = pending.into_iter().partition(|st| st.kind == "secret");
730    if steps.is_empty() {
731        s.push_str("Nothing is missing that you can install. ");
732    } else {
733        s.push_str("Steps still missing, in order:\n\n");
734        for (i, st) in steps.iter().enumerate() {
735            s.push_str(&format!("{}. **{}**", i + 1, st.title));
736            if st.status == "auto" {
737                s.push_str(" (`hlock sync --yes` does this)");
738            }
739            s.push('\n');
740            if !st.reason.is_empty() {
741                s.push_str(&format!("   - Why: {}\n", st.reason));
742            }
743            if let Some(c) = &st.command {
744                s.push_str(&format!("   - Suggested: `{c}`\n"));
745            }
746            if let Some(v) = &st.verify {
747                s.push_str(&format!("   - Check: `{v}`\n"));
748            }
749        }
750        s.push_str("\nThe suggested commands are a starting point: use what works on this machine, and pin the exact versions listed.\n\n");
751    }
752    if !secrets.is_empty() {
753        let names: Vec<String> = secrets.iter().map(|st| st.id.trim_start_matches("secret:").to_string()).collect();
754        s.push_str(&format!(
755            "These secrets are the user's to set; tell them which ones are missing at the end, and don't set them: {}.\n\n",
756            names.join(", ")
757        ));
758    }
759    s.push_str("Finish by running `hlock sync --yes`, which performs the remaining automatic steps.");
760    Ok((s, Some("hlock meta verify install".into())))
761}
762
763fn pack_goal(input: &JobInput) -> (String, Option<String>) {
764    let dir = input.pack_dir();
765    let shown = input.show(&dir);
766    let check = Some(format!("hlock meta verify bootstrap-evals --out {shown}"));
767    let name = input.manifest.as_ref().map(Manifest::name).unwrap_or_else(|| {
768        input.project.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "agent".into())
769    });
770    let text = std::fs::read_to_string(dir.join("pack.toml")).unwrap_or_default();
771    if text.is_empty() {
772        return (
773            format!("There's no eval pack in `{shown}` yet. Write one with `hlock evals init {shown} --template <template>` (`hlock evals templates` lists them), then fill it in with real tasks from this project ({name})."),
774            check,
775        );
776    }
777    let ids: Vec<String> = toml::from_str::<PackFile>(&text).map(|p| p.task.into_iter().map(|t| t.id).collect()).unwrap_or_default();
778    let open_slots = text.contains(PLACEHOLDER)
779        || EvalPack::load(&dir.to_string_lossy(), None).is_ok_and(|p| p.tasks.iter().any(|t| t.prompt_text.contains(PLACEHOLDER)));
780    let mut s = if open_slots {
781        format!(
782            "Fill in the eval pack template in `{shown}`: replace every `{PLACEHOLDER}` slot with a real task from this project ({name}), \
783             so the tasks look like the work its users actually hand to an agent. Aim for at least {} tasks.\n\n",
784            input.count
785        )
786    } else {
787        format!(
788            "Add tasks to the eval pack in `{shown}` (it has {}), drawn from real work in this project ({name}), until it has at least {}. \
789             Don't remove or rewrite existing tasks, and bump the pack's `version`.\n\n",
790            if ids.is_empty() { "no tasks".to_string() } else { ids.join(", ") },
791            input.count
792        )
793    };
794    match &input.guide {
795        Some(g) => s.push_str(&format!("Follow the guide in `{}`: where to look for tasks, the task kinds, and the rules for writing them.", input.show(g))),
796        None => s.push_str("Each task needs a prompt file, the fixtures it needs in `workspace/`, and an expected artifact a program can check. Never copy secrets or personal data into a task."),
797    }
798    if let Some(t) = input.manifest.as_ref().and_then(Manifest::target_harness) {
799        s.push_str(&format!("\n\nThe project's agent runs in {t}."));
800    }
801    (s, check)
802}
803
804fn update_goal(input: &JobInput, reg: &Registry, ctx: &Ctx) -> Result<(String, Option<String>)> {
805    let lock = input.load_lock()?;
806    let lock_name = input.show(&input.lock_path);
807    if !lock.is_pin() {
808        return Err(Error::Lockfile(format!(
809            "{lock_name} is a resolved lock; its components come from harness.toml, so update them there (or plan it with `hlock upgrade`). The update job works on pinned locks."
810        )));
811    }
812    let profile = reg.require_harness(&lock.harness.name)?;
813    let mut s = format!(
814        "Update the skills, MCP servers and plugins pinned in `{lock_name}` for {} to their newest upstream releases. \
815         Apply each update through {}'s own mechanism (its plugin commands, the package version in an MCP server's arguments, a skill's git source), \
816         skip anything that looks like a breaking change unless the user says otherwise, and finish with a short summary of what you changed and what you skipped.\n\n",
817        profile.name, profile.name
818    );
819    s.push_str(&format!("{}'s config root on this machine: `{}`\n\n", profile.name, profile.config_root(ctx).display()));
820    let mut any = false;
821    if !lock.skill.is_empty() {
822        any = true;
823        s.push_str("Skills:\n");
824        for sk in &lock.skill {
825            s.push_str(&format!("- {} ({}{})\n", sk.name, sk.origin, sk.source.as_deref().map(|x| format!(", {x}")).unwrap_or_default()));
826        }
827        s.push('\n');
828    }
829    if !lock.tool.is_empty() {
830        any = true;
831        s.push_str("MCP servers:\n");
832        for t in &lock.tool {
833            let how = match (&t.url, &t.command) {
834                (Some(u), _) => u.clone(),
835                (None, Some(c)) => std::iter::once(c.clone()).chain(t.args.iter().cloned()).collect::<Vec<_>>().join(" "),
836                _ => String::new(),
837            };
838            s.push_str(&format!("- {} ({}) {how}\n", t.name, t.transport.as_deref().unwrap_or("?")));
839        }
840        s.push('\n');
841    }
842    if !lock.native.is_empty() {
843        any = true;
844        s.push_str("Plugins:\n");
845        for n in &lock.native {
846            s.push_str(&format!("- {} {} ({}{})\n", n.name, n.version.as_deref().unwrap_or(""), n.kind, n.source.as_deref().map(|x| format!(", {x}")).unwrap_or_default()));
847        }
848        s.push('\n');
849    }
850    if !any {
851        s.push_str("The lock pins no skills, MCP servers or plugins, so there may be nothing to update.\n\n");
852    }
853    s.push_str(&format!("Don't edit `{lock_name}`; hlock re-pins it after you finish."));
854    Ok((s.trim_end().to_string(), Some("hlock meta verify update".into())))
855}
856
857// --------------------------------------------------------------------------- //
858// Verification
859// --------------------------------------------------------------------------- //
860
861#[derive(Debug, Clone, Serialize, Default)]
862pub struct Verdict {
863    pub job: String,
864    /// `verified`, `failed`, `needs-you` (only the user can finish it), or `unverified`.
865    pub status: String,
866    pub summary: String,
867    pub details: Vec<String>,
868    /// `install`: the secrets the user still has to set.
869    pub secrets: Vec<String>,
870    /// `update`: changes since the lock.
871    pub diff: Vec<String>,
872}
873
874impl Verdict {
875    fn new(job: &str, status: &str, summary: impl Into<String>) -> Verdict {
876        Verdict { job: job.into(), status: status.into(), summary: summary.into(), ..Default::default() }
877    }
878}
879
880/// hlock's own check of a job's result.
881pub fn verify(job: &str, input: &JobInput, meta: Option<&MetaManifest>, reg: &Registry, ctx: &Ctx) -> Result<Verdict> {
882    let spec = job_spec(job, meta)?;
883    match job {
884        "install" => {
885            let lock = input.load_lock()?;
886            let plan = sync::plan(&lock, &input.lock_path, reg, ctx, &SyncOptions::default())?;
887            let pending: Vec<&sync::Step> = plan.steps.iter().filter(|st| st.status != "satisfied" && !st.optional).collect();
888            let (secrets, steps): (Vec<&sync::Step>, Vec<&sync::Step>) = pending.into_iter().partition(|st| st.kind == "secret");
889            let secret_names: Vec<String> = secrets.iter().map(|st| st.id.trim_start_matches("secret:").to_string()).collect();
890            let mut v = if !steps.is_empty() {
891                let mut v = Verdict::new(job, "failed", format!("{} step{} still missing", steps.len(), if steps.len() == 1 { "" } else { "s" }));
892                v.details = steps.iter().map(|st| format!("{}{}", st.title, if st.reason.is_empty() { String::new() } else { format!(" ({})", st.reason) })).collect();
893                v
894            } else if !secrets.is_empty() {
895                Verdict::new(job, "needs-you", format!("installed; {} secret{} left for you to set", secrets.len(), if secrets.len() == 1 { "" } else { "s" }))
896            } else {
897                Verdict::new(job, "verified", format!("this machine matches {}", input.show(&input.lock_path)))
898            };
899            v.secrets = secret_names;
900            Ok(v)
901        }
902        "bootstrap-evals" => {
903            let dir = input.pack_dir();
904            let shown = input.show(&dir);
905            let pack = match EvalPack::load(&dir.to_string_lossy(), None) {
906                Ok(p) => p,
907                Err(e) => {
908                    let mut v = Verdict::new(job, "failed", format!("{shown} isn't an eval pack"));
909                    v.details.push(e.to_string());
910                    return Ok(v);
911                }
912            };
913            let issues = pack.check();
914            let line = |i: &crate::tasks::PackIssue| format!("{}{}{}", if i.level == "error" { "" } else { "warning: " }, i.task.as_deref().map(|t| format!("{t}: ")).unwrap_or_default(), i.message);
915            let errors: Vec<String> = issues.iter().filter(|i| i.level == "error").map(line).collect();
916            let n = pack.tasks.len();
917            let mut v = if errors.is_empty() {
918                Verdict::new(job, "verified", format!("{n} task{} in {shown}", if n == 1 { "" } else { "s" }))
919            } else {
920                let mut v = Verdict::new(job, "failed", format!("{shown} isn't ready: {} error{}", errors.len(), if errors.len() == 1 { "" } else { "s" }));
921                v.details = errors;
922                v
923            };
924            v.details.extend(issues.iter().filter(|i| i.level != "error").map(line));
925            if input.count > 0 && n < input.count && v.status == "verified" {
926                v.details.push(format!("asked for {}, got {n}", input.count));
927            }
928            Ok(v)
929        }
930        "update" => {
931            let lock = input.load_lock()?;
932            if !lock.is_pin() {
933                return Err(Error::Lockfile(format!("{} is a resolved lock; the update job works on pinned locks", input.show(&input.lock_path))));
934            }
935            let diff = crate::capture::check_pin(&lock, reg, ctx)?;
936            let mut v = if diff.is_empty() {
937                Verdict::new(job, "verified", "no changes: everything matches the lock")
938            } else {
939                Verdict::new(job, "verified", format!("{} change{} since {}", diff.len(), if diff.len() == 1 { "" } else { "s" }, input.show(&input.lock_path)))
940            };
941            v.diff = diff;
942            Ok(v)
943        }
944        _ => {
945            let spec = spec.expect("custom jobs have a spec");
946            let Some(cmd) = &spec.verify else {
947                return Ok(Verdict::new(job, "unverified", "this job has no `verify` command"));
948            };
949            let cmd = substitute(cmd, input);
950            let out = std::process::Command::new("sh")
951                .args(["-c", &cmd])
952                .current_dir(&input.project)
953                .env_clear()
954                .envs(&ctx.env)
955                .stdin(std::process::Stdio::null())
956                .output()
957                .map_err(|e| Error::Profile(format!("cannot run `{cmd}`: {e}")))?;
958            let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
959            let tail: Vec<String> = text.lines().rev().take(8).collect::<Vec<_>>().into_iter().rev().map(str::to_string).collect();
960            let mut v = if out.status.success() {
961                Verdict::new(job, "verified", format!("`{cmd}` passed"))
962            } else {
963                Verdict::new(job, "failed", format!("`{cmd}` exited with {}", out.status.code().unwrap_or(-1)))
964            };
965            v.details = tail;
966            Ok(v)
967        }
968    }
969}
970
971// --------------------------------------------------------------------------- //
972// Running a job
973// --------------------------------------------------------------------------- //
974
975/// Shell prefix that undoes isolation for one command: `env -u VAR` or `env VAR=value`.
976pub fn restore_prefix(changed: &BTreeMap<String, Option<String>>) -> String {
977    let mut parts = vec!["env".to_string()];
978    for (k, v) in changed {
979        match v {
980            Some(v) => parts.push(sync::shell_join(&[format!("{k}={v}")])),
981            None => parts.push(format!("-u {k}")),
982        }
983    }
984    parts.join(" ")
985}
986
987/// Put back the variables a meta session's isolation changed (see [`RESTORE_ENV`]),
988/// so an `hlock` command run by the meta harness acts on the user's real setup.
989pub fn restore_env(env: &mut BTreeMap<String, String>) {
990    let Some(marker) = env.remove(RESTORE_ENV) else { return };
991    let Ok(Value::Object(changed)) = serde_json::from_str::<Value>(&marker) else { return };
992    for (k, v) in changed {
993        match v.as_str() {
994            Some(orig) => {
995                env.insert(k, orig.to_string());
996            }
997            None => {
998                env.remove(&k);
999            }
1000        }
1001    }
1002}
1003
1004#[derive(Debug, Clone, Default)]
1005pub struct RunOptions {
1006    /// Run non-interactively (default: `harness.meta.toml`'s `[run] mode`, else interactive).
1007    pub headless: Option<bool>,
1008    /// Only build the command: what would stop the run becomes a note.
1009    pub dry_run: bool,
1010    pub allow_version_mismatch: bool,
1011}
1012
1013/// A job ready to run: the command, its environment, and the rendered prompt.
1014#[derive(Debug, Clone, Serialize, Default)]
1015pub struct Prepared {
1016    pub job: String,
1017    pub harness: String,
1018    pub harness_name: String,
1019    pub harness_version: Option<String>,
1020    /// `interactive` or `headless`.
1021    pub mode: String,
1022    pub argv: Vec<String>,
1023    pub env: BTreeMap<String, String>,
1024    pub root: Option<PathBuf>,
1025    pub prompt: String,
1026    /// True when the harness can't take a first message: the user points it at the prompt file.
1027    pub paste: bool,
1028    pub done: Vec<String>,
1029    pub notes: Vec<String>,
1030    pub missing_secrets: Vec<String>,
1031}
1032
1033/// Build the command that runs `job` in the meta harness pinned in `meta_lock`.
1034pub fn prepare(job: &str, input: &JobInput, meta: Option<&MetaManifest>, meta_lock: &Lockfile, reg: &Registry, ctx: &Ctx, opts: &RunOptions) -> Result<Prepared> {
1035    job_spec(job, meta)?;
1036    let builtin = builtin_profile();
1037    let profile = meta_profile(reg, &meta_lock.harness.name, &builtin)?;
1038    if profile.kind == "builtin" {
1039        return prepare_builtin(job, input, meta, meta_lock, reg, ctx, opts);
1040    }
1041    let code = profile.kind != "cli";
1042    let mut headless = opts.headless.unwrap_or_else(|| meta.is_some_and(MetaManifest::headless));
1043    let mut notes = Vec::new();
1044    if let Err(e) = preflight(reg, ctx, meta, profile, &mut notes) {
1045        if !opts.dry_run {
1046            return Err(e);
1047        }
1048        notes.push(e.to_string().trim_start_matches("profile error: ").to_string());
1049    }
1050    if code && !headless {
1051        notes.push(format!("{} is a code harness, so the job runs headless", profile.name));
1052        headless = true;
1053    }
1054    // headless runs use the profile's `[launch] prompt`; interactive ones its `interactive` arguments
1055    let (args, prompt, paste) = if code {
1056        (vec![PROMPT_TOKEN.to_string()], None, false)
1057    } else if headless {
1058        if profile.launch.prompt.is_empty() {
1059            return Err(Error::Profile(format!(
1060                "{} has no non-interactive mode in its profile ([launch] prompt); run the job interactively",
1061                profile.name
1062            )));
1063        }
1064        (Vec::new(), Some(PROMPT_TOKEN.to_string()), false)
1065    } else if profile.launch.interactive.is_empty() {
1066        (Vec::new(), None, true)
1067    } else {
1068        (profile.launch.interactive.iter().map(|a| a.replace("{prompt}", PROMPT_TOKEN)).collect(), None, false)
1069    };
1070    let lopts = LaunchOptions {
1071        allow_version_mismatch: opts.allow_version_mismatch,
1072        workspace: Some(input.project.clone()),
1073        prompt,
1074        ..Default::default()
1075    };
1076    let prep = launch::prepare(meta_lock, &input.meta_lock_path, reg, ctx, &lopts, &args)?;
1077    notes.extend(prep.notes.iter().cloned());
1078
1079    // what isolation changed, so hlock (and the agent) can undo it for the real setup
1080    let changed: BTreeMap<String, Option<String>> = if code {
1081        BTreeMap::new()
1082    } else {
1083        prep.env.keys().map(|k| (k.clone(), ctx.env.get(k).cloned())).collect()
1084    };
1085    let env_note = if changed.is_empty() {
1086        String::new()
1087    } else {
1088        let vars: Vec<String> = prep.env.iter().map(|(k, v)| format!("{k}={}", input.show(Path::new(v)))).collect();
1089        format!(
1090            "Your own configuration is isolated: this session runs with {}, and commands you run inherit it. \
1091             `hlock` commands put the user's real values back on their own. For any other command that must act on the user's real setup \
1092             (a harness's own CLI, installing plugins or skills), prefix it with `{}`.",
1093            vars.join(" "),
1094            restore_prefix(&changed)
1095        )
1096    };
1097    let prompt = render(job, input, meta, reg, ctx, &env_note)?;
1098    let fill = |s: &String| s.replace(PROMPT_TOKEN, &prompt);
1099    let mut env: BTreeMap<String, String> = prep.env.iter().map(|(k, v)| (k.clone(), fill(v))).collect();
1100    if !changed.is_empty() {
1101        let marker: serde_json::Map<String, Value> = changed.iter().map(|(k, v)| (k.clone(), v.clone().map(Value::String).unwrap_or(Value::Null))).collect();
1102        env.insert(RESTORE_ENV.into(), Value::Object(marker).to_string());
1103    }
1104    Ok(Prepared {
1105        job: job.into(),
1106        harness: profile.id.clone(),
1107        harness_name: profile.name.clone(),
1108        harness_version: meta_lock.harness.version.clone(),
1109        mode: if headless { "headless" } else { "interactive" }.into(),
1110        argv: prep.argv.iter().map(fill).collect(),
1111        env,
1112        root: prep.root.clone(),
1113        prompt,
1114        paste,
1115        done: prep.done,
1116        notes,
1117        missing_secrets: prep.missing_secrets,
1118    })
1119}
1120
1121/// The built-in agent runs as `hlock meta agent` (hlock's own interpreter), so a job runs,
1122/// streams and records the same way an external harness's does.
1123fn prepare_builtin(job: &str, input: &JobInput, meta: Option<&MetaManifest>, meta_lock: &Lockfile, reg: &Registry, ctx: &Ctx, opts: &RunOptions) -> Result<Prepared> {
1124    let headless = opts.headless.unwrap_or_else(|| meta.is_some_and(MetaManifest::headless));
1125    let mut notes = Vec::new();
1126    let builtin = builtin_profile();
1127    if let Err(e) = preflight(reg, ctx, meta, &builtin, &mut notes) {
1128        if !opts.dry_run {
1129            return Err(e);
1130        }
1131        notes.push(e.to_string());
1132    }
1133    let want = env!("CARGO_PKG_VERSION");
1134    if meta_lock.harness.version.as_deref() != Some(want) {
1135        let msg = format!("the meta lock pins hlock {}, this is hlock {want}", meta_lock.harness.version.as_deref().unwrap_or("?"));
1136        if !opts.allow_version_mismatch {
1137            return Err(Error::Profile(format!("{msg}; re-pin with `hlock meta pin`, or pass --allow-version-mismatch")));
1138        }
1139        notes.push(msg);
1140    }
1141    let prompt = render(job, input, meta, reg, ctx, "")?;
1142    let python = crate::profiles::python_for(None, ctx).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "python3".into());
1143    let argv = vec![
1144        python,
1145        "-m".into(),
1146        "harness_lock.cli".into(),
1147        "meta".into(),
1148        "agent".into(),
1149        "--meta-lock".into(),
1150        input.meta_lock_path.to_string_lossy().into_owned(),
1151        if headless { "--headless" } else { "--interactive" }.into(),
1152        prompt.clone(),
1153    ];
1154    Ok(Prepared {
1155        job: job.into(),
1156        harness: BUILTIN.into(),
1157        harness_name: builtin.name,
1158        harness_version: meta_lock.harness.version.clone(),
1159        mode: if headless { "headless" } else { "interactive" }.into(),
1160        argv,
1161        env: BTreeMap::new(),
1162        root: None,
1163        prompt,
1164        paste: false,
1165        done: Vec::new(),
1166        notes,
1167        missing_secrets: Vec::new(),
1168    })
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::*;
1174
1175    #[test]
1176    fn restore_env_round_trip() {
1177        let mut env: BTreeMap<String, String> = [
1178            ("CLAUDE_CONFIG_DIR".to_string(), "/p/.hlock/env/x".to_string()),
1179            ("XDG_CONFIG_HOME".to_string(), "/p/.hlock/env/y".to_string()),
1180            (RESTORE_ENV.to_string(), r#"{"CLAUDE_CONFIG_DIR": null, "XDG_CONFIG_HOME": "/home/u/.config"}"#.to_string()),
1181        ]
1182        .into();
1183        restore_env(&mut env);
1184        assert!(!env.contains_key("CLAUDE_CONFIG_DIR") && !env.contains_key(RESTORE_ENV));
1185        assert_eq!(env["XDG_CONFIG_HOME"], "/home/u/.config");
1186        let changed: BTreeMap<String, Option<String>> = [("A".to_string(), None), ("B".to_string(), Some("/x y".to_string()))].into();
1187        assert_eq!(restore_prefix(&changed), "env -u A 'B=/x y'");
1188    }
1189
1190    #[test]
1191    fn meta_manifest_rules() {
1192        let m = MetaManifest::from_str("[target]\nharness = \"pi\"\n[run]\nmode = \"headless\"\n[jobs.install]\ninstructions = \"use brew\"\n[jobs.notes]\nprompt = \"write notes\"\nverify = \"true\"\n").unwrap();
1193        assert_eq!(m.target_harness().as_deref(), Some("pi"));
1194        assert!(m.headless());
1195        let names: Vec<String> = jobs(Some(&m)).into_iter().map(|j| j.name).collect();
1196        assert_eq!(names, ["install", "bootstrap-evals", "update", "notes"]);
1197        assert!(MetaManifest::from_str("[jobs.install]\nprompt = \"x\"\n").is_err());
1198        assert!(MetaManifest::from_str("[jobs.notes]\nverify = \"true\"\n").is_err());
1199        assert!(MetaManifest::from_str("[run]\nmode = \"yolo\"\n").is_err());
1200        assert!(job_spec("nope", Some(&m)).is_err());
1201    }
1202}