1use 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
26pub const META_MANIFEST_PATH: &str = "harness.meta.toml";
28pub const META_LOCK_PATH: &str = "harness.meta.lock";
30pub const RUNS_DIR: &str = ".hlock/meta/runs";
32pub const RESTORE_ENV: &str = "HLOCK_META_RESTORE";
35
36const PROMPT_TOKEN: &str = "\u{1}hlock-meta-prompt\u{1}";
38
39pub const BUILTIN: &str = "builtin";
41pub const META_HARNESSES: &[&str] = &[BUILTIN, "pi", "codex", "goose", "opencode", "hermes"];
44pub const DEFAULT_MODELS: &[(&str, &str)] = &[("anthropic", "claude-sonnet-5-5"), ("openai", "gpt-5.5"), ("google", "gemini-3.5-flash")];
46
47pub 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59#[serde(default)]
60pub struct JobSpec {
61 pub description: Option<String>,
62 pub prompt: Option<String>,
64 pub verify: Option<String>,
66 pub instructions: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71#[serde(default)]
72pub struct RunDefaults {
73 pub mode: Option<String>,
75}
76
77#[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#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
97#[serde(default, rename_all = "kebab-case")]
98pub struct AgentSpec {
99 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 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
155pub fn is_builtin(name: &str) -> bool {
161 matches!(name, BUILTIN | "hlock" | "hlock-agent")
162}
163
164pub 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
178pub 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
198fn 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#[derive(Debug, Clone, Default)]
217pub struct InitOptions {
218 pub harness: Option<String>,
219 pub provider: Option<String>,
221 pub model: Option<String>,
222 pub runtime: Option<String>,
224 pub memory_gb: Option<f64>,
225 pub base_url: Option<String>,
227}
228
229#[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 pub install: Option<String>,
238 pub model: MetaModel,
239 pub model_why: String,
240 pub key_set: bool,
242 pub local: Option<LocalChoice>,
243}
244
245pub 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
316pub fn manifest_text(c: &Choice) -> String {
318 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
352pub 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
362pub 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
407fn 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
413pub 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
431fn 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
485fn 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
500pub 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
542fn 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#[derive(Debug, Clone, Serialize)]
573pub struct JobInfo {
574 pub name: String,
575 pub description: String,
576 pub builtin: bool,
577 pub verifies: bool,
579}
580
581pub 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#[derive(Debug, Clone, Default)]
615pub struct JobInput {
616 pub project: PathBuf,
618 pub lock_path: PathBuf,
620 pub manifest: Option<Manifest>,
621 pub manifest_path: Option<PathBuf>,
622 pub meta_lock_path: PathBuf,
623 pub out: Option<PathBuf>,
625 pub count: usize,
627 pub guide: Option<PathBuf>,
629 pub instructions: String,
631}
632
633impl JobInput {
634 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 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
664pub 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#[derive(Debug, Clone, Serialize, Default)]
862pub struct Verdict {
863 pub job: String,
864 pub status: String,
866 pub summary: String,
867 pub details: Vec<String>,
868 pub secrets: Vec<String>,
870 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
880pub 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
971pub 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
987pub 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 pub headless: Option<bool>,
1008 pub dry_run: bool,
1010 pub allow_version_mismatch: bool,
1011}
1012
1013#[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 pub mode: String,
1022 pub argv: Vec<String>,
1023 pub env: BTreeMap<String, String>,
1024 pub root: Option<PathBuf>,
1025 pub prompt: String,
1026 pub paste: bool,
1028 pub done: Vec<String>,
1029 pub notes: Vec<String>,
1030 pub missing_secrets: Vec<String>,
1031}
1032
1033pub 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 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 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
1121fn 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}