Files
OpenResearch/src/local/opencode.rs
T
Daniel Kim f633b80f9c Native modular agent skills: per-session worktree install + orx skill modules (v0.1.62) (#87)
* Native modular agent skills: per-session worktree install + orx skill modules (v0.1.62)

- Factor the 66KB SKILL.md into 10 embedded modules under agent-skills/
- Write the Local set as native SKILL.md skills into each orx up session
  worktree (harness-specific dir), fresh every turn beside the playbook
- Slim the playbook: compute/k8s/report detail moves to skills, generated
  skills index added; cardinal rules and command surface stay inline
- orx skill <name> serves bundled modules (API fallback unchanged);
  no-arg prints the slim root + module index
- orx install-skills --full writes the Full set globally (cloud box);
  Codex shim migrates to ~/.agents/skills (native, model-discoverable),
  legacy prompt kept

* agent-skills: store modules as literal SKILL.md files, installed verbatim

Each module is now a complete, GitHub-readable skill at
agent-skills/<name>/SKILL.md (frontmatter included); local-mode body
variants live as SKILL.local.md siblings. The render() step is gone —
files are embedded and installed byte-for-byte, and the frontmatter
drift test now validates the files against the code's name/description.

* Revert incidental ui/dist rebuild (no UI source changes in this branch)

* Drop leftover rebuilt dist assets — ui/dist now matches origin/main exactly

* agent-skills: trigger-rich descriptions with explicit liberal 'Use when' cues

Descriptions are the model's trigger surface; err toward false-positive
loads. Bound raised 150 -> 400 chars (Codex ambient budget is ~8k across
the set).

* Playbook: dedupe against skills + extract to literal SYSTEM_PROMPT.md

The system prompt is now a GitHub-readable template at repo root; playbook_md
strips the leading doc comment and substitutes {tokens} (project facts,
compute default, skills index). Content duplicated by the installed skills is
trimmed to pointers: the five per-backend exp-run table rows collapse to one,
the loop keeps only its skeleton, and the sizing/analysis sections fold into
one-liners. The prompt now explicitly tells the agent to load the relevant
skill before acting in its area (long-horizon staleness guard). Placeholder
test extended to all ten tokens + comment-stripping.

* orx-compute skill: add missing slurm + openresearch backend sections

The playbook trim removed the slurm/openresearch table rows, but orx-compute's
body (extracted from the old SKILL.md) never had sections for them — the
detail existed only in the playbook. Content moved from the pre-PR playbook
rows in the same per-backend section style. Also restore the shell-tool
time-limit rationale on the wait loop step in SYSTEM_PROMPT.md.
2026-07-17 17:47:24 -07:00

674 lines
27 KiB
Rust

//! opencode bootstrap for `orx up` — binary discovery, per-session config +
//! playbook written into the session's worktree, spawn + health check, and the
//! shared `AgentHost` handle the axum server holds (`Arc<AgentHost>` in state).
//!
//! One opencode serve child **per chat session**, cwd = that session's private
//! worktree (see `git::ensure_session_worktree`) so parallel agents never share
//! a checkout. Env is inherited (that's where ANTHROPIC_API_KEY /
//! OPENROUTER_API_KEY live — opencode auto-detects providers from env).
//! Children die with `orx up` via `kill_on_drop`.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::json;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use crate::error::{anyhow, Result};
use crate::local::git;
use crate::local::model::LocalProject;
use crate::store;
/// Playbook path inside the session worktree; opencode re-reads it every turn,
/// so rewriting the file retargets a running server without a restart.
const PLAYBOOK_REL: &str = ".openresearch/agent/autoresearch-local.md";
const HEALTH_TIMEOUT: Duration = Duration::from_secs(30);
/// `opencode` on PATH, else the installer's default drop location.
pub fn find_opencode() -> Result<PathBuf> {
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
let candidate = dir.join("opencode");
if candidate.is_file() {
return Ok(candidate);
}
}
}
if let Some(home) = dirs::home_dir() {
let fallback = home.join(".opencode").join("bin").join("opencode");
if fallback.is_file() {
return Ok(fallback);
}
}
Err(anyhow!(
"opencode not found (checked PATH and ~/.opencode/bin/opencode).\n\
Install it with: curl -fsSL https://opencode.ai/install | bash"
))
}
/// Ask the OS for a free loopback port (bind :0, read it back, release).
fn free_port() -> Result<u16> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|e| anyhow!("Could not pick a free port: {}", e))?;
Ok(listener.local_addr()?.port())
}
/// Where the spawned server's stdout/stderr land (startup diagnostics).
pub fn agent_log_path() -> PathBuf {
store::data_dir().join("agent-opencode.log")
}
/// Project-local opencode config. Every real permission is pre-approved so a
/// headless turn never stalls on a TUI prompt; the interactive `question` tool
/// is denied AND disabled (it would deadlock serve mode — nothing can answer
/// it), repeated on the default `build` agent because the tool filter is
/// agent-scoped. `model` only when the user passed `orx up --model`.
fn opencode_config_json(model: Option<&str>, instructions: &str) -> String {
let mut cfg = json!({
"$schema": "https://opencode.ai/config.json",
"permission": {
"edit": "allow",
"bash": "allow",
"webfetch": "allow",
"websearch": "allow",
"read": "allow",
"glob": "allow",
"grep": "allow",
"task": "allow",
"skill": "allow",
"lsp": "allow",
"doom_loop": "allow",
"external_directory": "allow",
"question": "deny",
},
"tools": { "question": false },
"agent": {
"build": { "tools": { "question": false }, "permission": { "question": "deny" } }
},
"instructions": [instructions],
});
if let Some(model) = model {
cfg["model"] = json!(model);
}
serde_json::to_string_pretty(&cfg).unwrap_or_else(|_| "{}".to_string())
}
/// The local-mode autoresearch playbook: project context + cardinal rules +
/// the v1 local command surface. Ported from the cloud agent's
/// `autoresearchMd()`/`projectContextMd()` prompts, adapted for `orx up`
/// (external backends via `--backend`, analysis via `orx logs`, no
/// artifacts/query/chart).
/// The playbook template — a literal, GitHub-readable markdown file. Rendered
/// by [`playbook_md`]: the leading HTML comment is stripped and `{token}`
/// placeholders are substituted (project facts, the compute default, the
/// skills index).
const SYSTEM_PROMPT: &str = include_str!("../../SYSTEM_PROMPT.md");
fn playbook_md(project: &LocalProject) -> String {
let id = &project.id;
let name = &project.name;
let repo = format!("{}/{}", project.github_owner, project.github_repo);
let baseline = &project.baseline_branch;
let files = super::files::files_dir(project)
.to_string_lossy()
.into_owned();
let paper_line = project.paper_id.as_deref().map_or(String::new(), |p| {
format!(
"- Paper: arXiv {p} (https://arxiv.org/abs/{p}) — the paper this project starts \
from; `orx paper {p}` fetches its report\n"
)
});
// The default compute target (Settings → Compute) is read fresh on every
// playbook rewrite, but how soon a rewrite reaches a live agent varies:
// claude re-injects the playbook per turn; codex only on thread
// start/resume; a live opencode server keeps its playbook until respawn
// (`AgentHost::ensure` early-returns for a running child). Launch-time
// resolution in `exp run` stays authoritative either way: the agent is
// told to OMIT `--backend`, never to echo the default back, so even a
// stale prompt launches on the current default.
let compute_default = crate::config::compute_default();
let compute_bullet = match &compute_default {
Some((b, f)) => {
let flavor_part = f
.as_ref()
.map_or(String::new(), |f| format!(" (`--flavor {f}`)"));
format!(
"- Compute: default target **{b}**{flavor_part} — the user set it in \
Settings → Compute; omit `--backend` on `orx exp run` to launch there. \
Use another backend only when the user names one (see \"Compute backends\")"
)
}
None => "- Compute: backends — `hf`, `modal`, `k8s`, `ssh`, `slurm`, or `local` —\n \
chosen by the user per run; **there is no default backend** (see \"Compute\n \
backends\")"
.to_string(),
};
let backends_intro = match &compute_default {
Some((b, f)) => {
let flavor_part = f
.as_ref()
.map_or(String::new(), |f| format!(" --flavor {f}"));
// The omit-instruction must match what a bare launch actually
// needs: with a saved flavor both flags can go; a flavor-required
// backend without one still needs --flavor; ssh always needs
// --host. Contradicting the launch validation here sends the
// agent into a guaranteed-failing command.
let omit_hint = if f.is_some() {
"Omit it (and `--flavor`) to use the default.".to_string()
} else if super::FLAVOR_REQUIRED_BACKENDS.contains(&b.as_str()) {
"Omit `--backend` to use the default, but still pass `--flavor` — no default \
flavor is saved."
.to_string()
} else {
"Omit it to use the default.".to_string()
};
let mut s = format!(
"`orx exp run` launches on the user's configured default — **{b}{flavor_part}** \
— when you omit `--backend`. {omit_hint} \
Deviate only when the user names another backend for the task or this \
conversation — a connected token for some other backend is NOT a signal to \
switch."
);
if b == "ssh" {
s.push_str(" (`--host <alias>` is still required on every launch.)");
}
s
}
None => "`orx exp run` requires an explicit `--backend` — **there is no default**.\n\
Which backend to use is the user's decision: if the task doesn't name one and\n\
the user hasn't already picked one in this conversation, ask before launching."
.to_string(),
};
let launch_step = if compute_default.is_some() {
"3. **Launch**: `orx exp run <expId>` — omitting `--backend` uses the default\n \
target (flags the default still needs are listed under \"Compute backends\") —\n \
or name one explicitly (`--flavor` for hf/modal, `--host` for ssh/slurm; k8s\n \
reads the committed manifest; local takes no flags)."
} else {
"3. **Launch**: `orx exp run <expId> --backend <backend>` (`--flavor` for\n \
hf/modal, `--host` for ssh/slurm; k8s reads the committed manifest; local\n \
takes no flags)."
};
// The modular skills installed into this session's worktree (see
// `agent_skills::ensure_session_skills`). Generated from the Local set so
// the playbook index and the files on disk can never drift.
let skills_list = super::agent_skills::skills(super::agent_skills::SkillSet::Local)
.iter()
.map(|s| format!("- **{}** — {}", s.name, s.description))
.collect::<Vec<_>>()
.join("\n");
let template = SYSTEM_PROMPT
.split_once("-->\n\n")
.map(|(_, rest)| rest)
.unwrap_or(SYSTEM_PROMPT);
template
.replace("{name}", name)
.replace("{id}", id)
.replace("{repo}", &repo)
.replace("{baseline}", baseline)
.replace("{paper_line}", &paper_line)
.replace("{compute_bullet}", &compute_bullet)
.replace("{files}", &files)
.replace("{skills_list}", &skills_list)
.replace("{launch_step}", launch_step)
.replace("{backends_intro}", &backends_intro)
}
/// Keep the files we drop into the checkout out of `git status` / accidental
/// commits via the local-only `.git/info/exclude` (never touches tracked
/// files or the repo's own `.gitignore`). Takes the **hub clone** path — its
/// `.git/info/exclude` is shared by every session worktree (a worktree's own
/// `.git` is just a pointer file). Best-effort.
fn exclude_agent_files(hub: &Path) {
let path = hub.join(".git").join("info").join("exclude");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let missing: Vec<&str> = [
"opencode.json",
".openresearch/",
".claude/skills/",
".opencode/skills/",
".agents/skills/",
]
.into_iter()
.filter(|entry| !existing.lines().any(|l| l.trim() == *entry))
.collect();
if missing.is_empty() {
return;
}
let mut block = String::new();
if !existing.is_empty() && !existing.ends_with('\n') {
block.push('\n');
}
for entry in missing {
block.push_str(entry);
block.push('\n');
}
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| std::io::Write::write_all(&mut f, block.as_bytes()));
}
/// Ensure the project's hub clone and this session's private worktree exist,
/// and write the autoresearch playbook into the worktree. Every harness
/// adapter injects this same file (opencode via config `instructions`, Claude
/// Code via `--append-system-prompt`, Codex via `developerInstructions` —
/// legacy exec: first-turn context). Returns
/// `(workdir, playbook)` — the worktree the harness runs in and the playbook
/// path inside it.
///
/// `session_skills_dir` is the harness's worktree-relative native-skills dir
/// (`.claude/skills`, `.opencode/skills`, `.agents/skills`); when `Some`, the
/// modular `orx` skills are written there too, fresh alongside the playbook, so
/// the session's own agent auto-loads them with zero drift.
pub fn ensure_playbook(
project: &LocalProject,
session_id: &str,
session_skills_dir: Option<&str>,
) -> Result<(PathBuf, PathBuf)> {
let workdir = git::ensure_session_worktree(
&project.github_owner,
&project.github_repo,
&project.baseline_branch,
session_id,
)?;
let playbook = workdir.join(PLAYBOOK_REL);
if let Some(parent) = playbook.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow!("Could not create {}: {}", parent.display(), e))?;
}
std::fs::write(&playbook, playbook_md(project))
.map_err(|e| anyhow!("Could not write {}: {}", playbook.display(), e))?;
// Modular skills, written fresh beside the playbook (same freshness
// semantics) so this session's agent discovers them natively.
if let Some(dir) = session_skills_dir {
super::agent_skills::ensure_session_skills(&workdir, dir)?;
}
// One shared exclude covers every worktree.
exclude_agent_files(&git::clone_path(
&project.github_owner,
&project.github_repo,
));
// The playbook points the agent at the files dir — make sure it exists.
let _ = super::files::ensure_dir(project);
Ok((workdir, playbook))
}
/// Write the opencode config + the playbook into the session's worktree
/// (self-healing via `ensure_session_worktree` if the cache was wiped).
/// Returns the worktree path plus, when the repo tracks its own
/// `opencode.json` (which we must never clobber — the agent commits and
/// pushes from this worktree), the path of our config to pass via
/// `OPENCODE_CONFIG` instead.
fn write_agent_files(
project: &LocalProject,
model: Option<&str>,
session_id: &str,
) -> Result<(PathBuf, Option<PathBuf>)> {
// Source of truth for the session-skills dir is the harness trait.
use crate::local::harness::Harness;
let skills_dir = crate::local::harness::opencode::OpenCode.session_skills_dir();
let (repo, playbook) = ensure_playbook(project, session_id, skills_dir)?;
let config_override = if git::is_tracked(&repo, "opencode.json") {
// Out-of-root config: absolute instructions path (no root to anchor it).
let path = repo
.join(".openresearch")
.join("agent")
.join("opencode.json");
std::fs::write(
&path,
opencode_config_json(model, &playbook.to_string_lossy()),
)
.map_err(|e| anyhow!("Could not write {}: {}", path.display(), e))?;
Some(path)
} else {
std::fs::write(
repo.join("opencode.json"),
opencode_config_json(model, PLAYBOOK_REL),
)
.map_err(|e| anyhow!("Could not write opencode.json: {}", e))?;
None
};
Ok((repo, config_override))
}
/// Wire status of one serve child for `GET /api/agent/status`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentStatus {
pub running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
struct AgentChild {
child: Child,
port: u16,
project_id: String,
session_id: String,
model: Option<String>,
}
impl AgentChild {
fn status(&self) -> AgentStatus {
AgentStatus {
running: true,
port: Some(self.port),
project_id: Some(self.project_id.clone()),
session_id: Some(self.session_id.clone()),
model: self.model.clone(),
}
}
}
/// Poll `/global/health` until opencode answers, watching for early exit.
async fn wait_healthy(child: &mut Child, port: u16) -> Result<()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()?;
let url = format!("http://127.0.0.1:{port}/global/health");
let deadline = Instant::now() + HEALTH_TIMEOUT;
loop {
if let Some(status) = child.try_wait()? {
return Err(anyhow!(
"opencode exited during startup ({status}); see {}",
agent_log_path().display()
));
}
if let Ok(resp) = client.get(&url).send().await {
if resp.status().is_success() {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(anyhow!(
"opencode did not become healthy on 127.0.0.1:{port} within {}s; see {}",
HEALTH_TIMEOUT.as_secs(),
agent_log_path().display()
));
}
tokio::time::sleep(Duration::from_millis(400)).await;
}
}
/// Spawn `opencode serve` in the session's worktree and wait for it to come
/// up healthy.
async fn spawn_agent(
project: &LocalProject,
model: Option<&str>,
session_id: &str,
) -> Result<AgentChild> {
let bin = find_opencode()?;
// The clone/worktree setup inside can hit the network; keep it off the
// async workers.
let (repo, config_override) = {
let (project, model) = (project.clone(), model.map(str::to_string));
let session = session_id.to_string();
tokio::task::spawn_blocking(move || write_agent_files(&project, model.as_deref(), &session))
.await
.map_err(|e| anyhow!("agent file task failed: {e}"))??
};
// Best-effort: the playbook is the real guide; the shim just lets
// opencode's skill tool surface `orx skill` too.
if let Err(err) = crate::commands::install_skills::install_opencode_shim().await {
eprintln!("warning: could not install the orx opencode skill: {err}");
}
let port = free_port()?;
// The data dir may not exist yet (fresh machine, no Store::open before us).
if let Some(parent) = agent_log_path().parent() {
std::fs::create_dir_all(parent)
.map_err(|e| anyhow!("Could not create {}: {}", parent.display(), e))?;
}
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(agent_log_path())
.map_err(|e| anyhow!("Could not open {}: {}", agent_log_path().display(), e))?;
let mut cmd = Command::new(&bin);
cmd.arg("serve")
.arg("--port")
.arg(port.to_string())
.arg("--hostname")
.arg("127.0.0.1")
// Without --print-logs the log file stays empty and startup failures
// are undiagnosable.
.arg("--print-logs")
.current_dir(&repo)
.stdin(Stdio::null())
.stdout(Stdio::from(log.try_clone().map_err(|e| anyhow!("{e}"))?))
.stderr(Stdio::from(log))
// Dies with `orx up` when the runtime drops the handle (Ctrl-C, exit).
.kill_on_drop(true);
// The agent shells out to plain `orx`; prepend this binary's dir so it
// resolves to THIS orx (with local mode), not an older install on PATH.
if let Ok(exe) = std::env::current_exe().and_then(|p| p.canonicalize()) {
if let Some(dir) = exe.parent() {
let mut path = std::ffi::OsString::from(dir);
match std::env::var_os("PATH") {
Some(existing) if !existing.is_empty() => {
path.push(":");
path.push(existing);
}
_ => {}
}
cmd.env("PATH", path);
}
}
// Vars saved in the dashboard's Environment tab reach the agent too;
// the real process env still wins on conflicts.
for (key, value) in crate::config::list_synced_env() {
if std::env::var_os(&key).is_none() {
cmd.env(key, value);
}
}
if let Some(config) = &config_override {
// The repo tracks its own opencode.json; ours rides OPENCODE_CONFIG.
// Project configs load after OPENCODE_CONFIG and would override our
// headless permission grants, so they are disabled for this child.
cmd.env("OPENCODE_CONFIG", config)
.env("OPENCODE_DISABLE_PROJECT_CONFIG", "1");
}
// Own process group: a terminal SIGINT reaches orx up alone, which then
// tears the child down deliberately (kill_on_drop / shutdown()).
#[cfg(unix)]
cmd.process_group(0);
let mut child = cmd
.spawn()
.map_err(|e| anyhow!("Could not spawn {}: {}", bin.display(), e))?;
if let Err(err) = wait_healthy(&mut child, port).await {
let _ = child.kill().await;
return Err(err);
}
Ok(AgentChild {
child,
port,
project_id: project.id.clone(),
session_id: session_id.to_string(),
model: model.map(str::to_string),
})
}
/// The `orx up` opencode host: one serve child per chat session, keyed by the
/// orx session id, each running in that session's worktree. Share as
/// `Arc<AgentHost>` in axum state.
pub struct AgentHost {
/// `orx up --model` override, applied to every spawn.
model_override: Option<String>,
/// Serializes ensure() spawns (across all sessions — a spawn is seconds,
/// and one at a time keeps clone/fetch traffic sane). Never taken by
/// status()/port_for(), and `inner` is never held across a spawn — a slow
/// clone or health poll must not block status reads or turn replies.
spawn_lock: Mutex<()>,
inner: Mutex<HashMap<String, AgentChild>>,
}
impl AgentHost {
pub fn new(model_override: Option<String>) -> Self {
Self {
model_override,
spawn_lock: Mutex::new(()),
inner: Mutex::new(HashMap::new()),
}
}
/// Status of every live child; reaps children that died behind our back.
pub async fn status(&self) -> Vec<AgentStatus> {
let mut guard = self.inner.lock().await;
guard.retain(|_, agent| matches!(agent.child.try_wait(), Ok(None)));
guard.values().map(AgentChild::status).collect()
}
/// Loopback port of the session's live server (for inline replies/aborts).
pub async fn port_for(&self, session_id: &str) -> Option<u16> {
let mut guard = self.inner.lock().await;
let agent = guard.get_mut(session_id)?;
if matches!(agent.child.try_wait(), Ok(None)) {
Some(agent.port)
} else {
guard.remove(session_id);
None
}
}
/// Spawn (or reuse) the opencode server for this session. Idempotent when
/// the session's server is already alive; a dead child is replaced.
pub async fn ensure(&self, project: &LocalProject, session_id: &str) -> Result<AgentStatus> {
let _spawning = self.spawn_lock.lock().await;
{
let mut guard = self.inner.lock().await;
if let Some(agent) = guard.get_mut(session_id) {
if agent.project_id == project.id && matches!(agent.child.try_wait(), Ok(None)) {
return Ok(agent.status());
}
}
if let Some(mut old) = guard.remove(session_id) {
let _ = old.child.kill().await; // kill() also reaps
}
}
// inner released: status()/port reads keep answering while the spawn
// (clone/fetch + health poll) is in flight instead of hanging.
let agent = spawn_agent(project, self.model_override.as_deref(), session_id).await?;
let status = agent.status();
self.inner
.lock()
.await
.insert(session_id.to_string(), agent);
Ok(status)
}
/// Kill and reap one session's child (on session delete). No-op when the
/// session has none.
pub async fn kill_session(&self, session_id: &str) {
if let Some(mut agent) = self.inner.lock().await.remove(session_id) {
let _ = agent.child.kill().await;
}
}
/// Kill and reap every child (also happens via kill_on_drop on exit).
pub async fn shutdown(&self) {
for (_, mut agent) in self.inner.lock().await.drain() {
let _ = agent.child.kill().await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::local::agent_skills::{self, SkillSet};
fn sample_project() -> LocalProject {
LocalProject {
id: "proj_test".into(),
name: "Test Project".into(),
slug: "test-project".into(),
github_owner: "acme".into(),
github_repo: "widget".into(),
baseline_branch: "main".into(),
repo_path: "/tmp/nonexistent".into(),
run_command: None,
paper_id: None,
created_at: 0,
updated_at: 0,
}
}
/// The playbook's "## Skills" index must list exactly the Local-set skills,
/// in order — regenerate-and-compare so it can never freeze out of sync with
/// `agent_skills::skills` (the same set written into the session worktree).
#[test]
fn playbook_skills_index_matches_local_set() {
let md = playbook_md(&sample_project());
let expected: Vec<String> = agent_skills::skills(SkillSet::Local)
.iter()
.map(|s| format!("- **{}** — {}", s.name, s.description))
.collect();
// The "## Skills" section body: between the heading and the next `## `.
let after = md
.split("## Skills\n")
.nth(1)
.expect("no ## Skills section");
let section = after.split("\n## ").next().unwrap();
let listed: Vec<String> = section
.lines()
.filter(|l| l.starts_with("- **"))
.map(str::to_string)
.collect();
assert_eq!(
listed, expected,
"playbook Skills index drifted from Local set"
);
}
/// The slimmed playbook keeps its templated conditional logic — the
/// compute-default branch's placeholders must still resolve (no leftover
/// `{...}` braces from a botched edit).
#[test]
fn playbook_has_no_unresolved_placeholders() {
let md = playbook_md(&sample_project());
// Every token the template may carry must be substituted — a typo'd or
// newly added token that playbook_md doesn't know about fails here.
for token in [
"{name}",
"{id}",
"{repo}",
"{baseline}",
"{paper_line}",
"{compute_bullet}",
"{files}",
"{skills_list}",
"{launch_step}",
"{backends_intro}",
] {
assert!(!md.contains(token), "unresolved placeholder {token}");
}
// The template's leading HTML comment (repo-reader documentation) must
// be stripped — the prompt starts at the title.
assert!(
md.starts_with("# OpenResearch local agent"),
"template comment not stripped"
);
assert!(!md.contains("<!--"), "HTML comment leaked into the prompt");
// Sanity: the slimmed pointers to the modules survived.
assert!(md.contains("orx-compute"));
assert!(md.contains("orx-reports"));
assert!(md.contains("orx-evidence"));
}
}