Skip to content

Commit 3628924

Browse files
committed
feat(dev): add research init scaffolding workflow
1 parent de214cb commit 3628924

5 files changed

Lines changed: 309 additions & 28 deletions

File tree

README.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ A single-binary Rust CLI for unified developer workflows:
1414
- Versioning (`dev version ...`)
1515
- `.env` helpers (`dev env ...`)
1616
- System setup (`dev setup ...`)
17-
- Dockerized GPU dev containers (`dev docker ...`)
18-
- Review overlays and directory manifests (`dev review`, `dev walk`)
17+
- Dockerized GPU dev containers (`dev docker ...`)
18+
- Isolated research project scaffolding (`dev research init`)
19+
- Review overlays and directory manifests (`dev review`, `dev walk`)
1920

2021
### `devkey` (Windows only)
2122

@@ -84,7 +85,7 @@ dev install [rust|python|typescript]
8485
dev language set <name>
8586
```
8687

87-
### `.env` management
88+
### `.env` management
8889

8990
```bash
9091
dev env [--raw]
@@ -101,8 +102,20 @@ dev env init
101102
dev env template
102103

103104
dev env diff [REF]
104-
dev env sync [REF]
105-
```
105+
dev env sync [REF]
106+
```
107+
108+
### Research management
109+
110+
```bash
111+
# Scaffold an isolated, project-local research workspace
112+
dev research init [DIR] --name <project-name> --package <python_package>
113+
114+
# Preview without writing files
115+
dev --dry-run research init ./research/heu-exp-01
116+
```
117+
118+
`dev research init` creates a clean-room research layout (`project.yaml`, `experiments/`, `src/<package>/bindings/`, `reports/templates/`, `.harness/`) and sets `HARNESS_HOME=.harness` so runs remain project-local and commit-friendly.
106119

107120
## Docker workflow (GPU dev container)
108121

crates/dev/src/cli.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ pub enum Command {
145145
#[command(subcommand)]
146146
command: DockerCommand,
147147
},
148+
/// Research workflow management (isolated harness projects).
149+
Research {
150+
#[command(subcommand)]
151+
command: ResearchCommand,
152+
},
148153
/// Kubernetes helpers (contexts, namespaces, quick info)
149154
Kube {
150155
#[command(subcommand)]
@@ -164,6 +169,42 @@ pub enum Command {
164169
External(Vec<String>),
165170
}
166171

172+
#[derive(Subcommand, Debug)]
173+
pub enum ResearchCommand {
174+
/// Scaffold an isolated, project-local research workspace.
175+
Init(ResearchInitArgs),
176+
}
177+
178+
#[derive(Args, Debug)]
179+
pub struct ResearchInitArgs {
180+
/// Target directory for the research project (default: current directory).
181+
#[arg(default_value = ".")]
182+
pub directory: PathBuf,
183+
184+
/// Project name written into project.yaml (default: directory name).
185+
#[arg(long = "name")]
186+
pub name: Option<String>,
187+
188+
/// Python package name for reusable bindings/logic.
189+
#[arg(long = "package")]
190+
pub package: Option<String>,
191+
192+
/// Overwrite scaffold files if they already exist.
193+
#[arg(long = "force", default_value_t = false)]
194+
pub force: bool,
195+
196+
/// Skip automatic harness dependency install with uv.
197+
#[arg(long = "skip-install", default_value_t = false)]
198+
pub skip_install: bool,
199+
200+
/// Git URL used to install research-harness.
201+
#[arg(
202+
long = "harness-git",
203+
default_value = "https://github.com/hydra-dynamix/research-harness-2.git"
204+
)]
205+
pub harness_git: String,
206+
}
207+
167208
#[derive(Subcommand, Debug)]
168209
pub enum OsCommand {
169210
/// Switch to Windows configuration (uses npx.cmd, .cmd extensions).

crates/dev/src/runner.rs

Lines changed: 227 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
use std::io::{BufRead, BufReader};
2-
use std::path::{Path, PathBuf};
3-
use std::process::{Command as ProcessCommand, Stdio};
4-
use std::thread;
5-
use std::time::Instant;
1+
use std::io::{BufRead, BufReader};
2+
use std::path::{Path, PathBuf};
3+
use std::process::{Command as ProcessCommand, Stdio};
4+
use std::thread;
5+
use std::time::Instant;
66
use std::{fs, io};
77

88
use anyhow::{Context, Result, anyhow, bail};
99
use camino::Utf8PathBuf;
1010
use clap::Parser;
1111

12-
use crate::cli::{
13-
Cli, Command, ConfigCommand, DockerBuildArgs, DockerCommand, DockerComposeCommand,
14-
DockerComposeUpCommand, DockerComposeUpBuildArgs, DockerInitArgs, EnvArgs, EnvCommand,
15-
GitCommand, InstallArgs, KubeCommand, LanguageCommand, OsCommand, SetupCommand, StartArgs,
16-
VaultCommand, Verb, VersionCommand,
17-
};
12+
use crate::cli::{
13+
Cli, Command, ConfigCommand, DockerBuildArgs, DockerCommand, DockerComposeCommand,
14+
DockerComposeUpCommand, DockerComposeUpBuildArgs, DockerInitArgs, EnvArgs, EnvCommand,
15+
GitCommand, InstallArgs, KubeCommand, LanguageCommand, OsCommand, ResearchCommand,
16+
ResearchInitArgs, SetupCommand, StartArgs, VaultCommand, Verb, VersionCommand,
17+
};
1818
use crate::config::{DevConfig, TaskUpdateMode};
1919
use crate::envfile;
2020
use crate::tasks::{CommandSpec, TaskIndex};
@@ -157,8 +157,8 @@ pub fn run(cli: Cli) -> Result<()> {
157157
}
158158

159159
fn handle_with_state(state: &AppState, command: Command) -> Result<()> {
160-
match command {
161-
Command::List => handle_list(state),
160+
match command {
161+
Command::List => handle_list(state),
162162
Command::Run { task } => handle_run(state, &task),
163163
Command::Start(args) => handle_start(state, args),
164164
Command::Fmt => handle_verb(state, Verb::Fmt),
@@ -174,8 +174,9 @@ fn handle_with_state(state: &AppState, command: Command) -> Result<()> {
174174
Command::Git { command } => handle_git(state, command),
175175
Command::Version { command } => handle_version(state, command),
176176
Command::Env(args) => handle_env(state, args),
177-
Command::Docker { command } => handle_docker(state, command),
178-
Command::Kube { command } => handle_kube(state, command),
177+
Command::Docker { command } => handle_docker(state, command),
178+
Command::Research { command } => handle_research(state, command),
179+
Command::Kube { command } => handle_kube(state, command),
179180
Command::Vault { command } => handle_vault(state, command),
180181
Command::Os { command } => handle_os(state, command),
181182
Command::Config { .. } => unreachable!("config commands handled earlier"),
@@ -186,7 +187,217 @@ fn handle_with_state(state: &AppState, command: Command) -> Result<()> {
186187
bail!("unknown command: {}", extra.join(" "))
187188
}
188189
}
189-
}
190+
}
191+
192+
fn handle_research(state: &AppState, command: ResearchCommand) -> Result<()> {
193+
match command {
194+
ResearchCommand::Init(args) => research_init(state, args),
195+
}
196+
}
197+
198+
fn research_init(state: &AppState, args: ResearchInitArgs) -> Result<()> {
199+
let target = if args.directory.is_absolute() {
200+
args.directory.clone()
201+
} else {
202+
std::env::current_dir()?.join(&args.directory)
203+
};
204+
let target = target
205+
.canonicalize()
206+
.unwrap_or_else(|_| target.clone());
207+
208+
let project_name = args.name.unwrap_or_else(|| {
209+
target
210+
.file_name()
211+
.map(|s| s.to_string_lossy().to_string())
212+
.filter(|s| !s.trim().is_empty())
213+
.unwrap_or_else(|| "research-project".to_owned())
214+
});
215+
let package_name = args
216+
.package
217+
.unwrap_or_else(|| normalize_package_name(&project_name));
218+
219+
if target.exists() && target.read_dir()?.next().is_some() && !args.force {
220+
bail!(
221+
"target directory is not empty: {} (rerun with --force to overwrite scaffold files)",
222+
target.display()
223+
);
224+
}
225+
226+
if state.ctx.dry_run {
227+
println!("[dry-run] would initialize research project at {}", target.display());
228+
println!("[dry-run] project name: {}", project_name);
229+
println!("[dry-run] package name: {}", package_name);
230+
if !args.skip_install {
231+
println!(
232+
"[dry-run] would run: uv add \"research-harness @ git+{}\"",
233+
args.harness_git
234+
);
235+
}
236+
return Ok(());
237+
}
238+
239+
fs::create_dir_all(&target)
240+
.with_context(|| format!("creating {}", target.display()))?;
241+
242+
let project_yaml = format!(
243+
"name: {name}\nversion: \"0.1.0\"\ndescription: \"\"\n\nexperiments:\n - id: example\n module: experiments.example\n callable: run\n description: \"Example experiment\"\n\nconfig:\n default: configs/default.yaml\n\ndatasets: []\n\nthresholds: configs/thresholds.yaml\n\noutputs:\n format: parquet\n",
244+
name = project_name
245+
);
246+
247+
let default_config = "# Default experiment configuration.\n";
248+
let thresholds = "thresholds: {}\n";
249+
let experiments_init = "\"\"\"Experiments package.\"\"\"\n";
250+
let example_exp = "\"\"\"Example experiment module.\"\"\"\n\n\ndef run(seed: int, output_dir, **kwargs):\n \"\"\"Run the example experiment.\"\"\"\n return {\"status\": \"ok\", \"seed\": seed}\n";
251+
let package_init = format!("\"\"\"Reusable package for {}.\"\"\"\n", project_name);
252+
let bindings_init = "\"\"\"Bindings for target systems.\"\"\"\n";
253+
let binding_example = "\"\"\"Example binding stub.\n\nImplement clean-room adapters here.\n\"\"\"\n";
254+
let analysis_tpl = "# Analysis Report\n\n## Scope\n- Hypothesis:\n- Dataset(s):\n- Config + seed policy:\n\n## Results\n- Run IDs:\n- Threshold outcomes:\n- Key observations:\n\n## Risks / Caveats\n-\n";
255+
let synthesis_tpl = "# Meta-Synthesis\n\n## Experiments Included\n-\n\n## Cross-Experiment Findings\n-\n\n## Plain-Language Overview\n-\n";
256+
let env_example = "HARNESS_HOME=.harness\n";
257+
let env_local = "HARNESS_HOME=.harness\n";
258+
259+
write_scaffold_file(&target.join("project.yaml"), &project_yaml, args.force)?;
260+
write_scaffold_file(
261+
&target.join("configs").join("default.yaml"),
262+
default_config,
263+
args.force,
264+
)?;
265+
write_scaffold_file(
266+
&target.join("configs").join("thresholds.yaml"),
267+
thresholds,
268+
args.force,
269+
)?;
270+
write_scaffold_file(
271+
&target.join("experiments").join("__init__.py"),
272+
experiments_init,
273+
args.force,
274+
)?;
275+
write_scaffold_file(
276+
&target.join("experiments").join("example.py"),
277+
example_exp,
278+
args.force,
279+
)?;
280+
write_scaffold_file(
281+
&target.join("src").join(&package_name).join("__init__.py"),
282+
&package_init,
283+
args.force,
284+
)?;
285+
write_scaffold_file(
286+
&target
287+
.join("src")
288+
.join(&package_name)
289+
.join("bindings")
290+
.join("__init__.py"),
291+
bindings_init,
292+
args.force,
293+
)?;
294+
write_scaffold_file(
295+
&target
296+
.join("src")
297+
.join(&package_name)
298+
.join("bindings")
299+
.join("example.py"),
300+
binding_example,
301+
args.force,
302+
)?;
303+
write_scaffold_file(
304+
&target
305+
.join("reports")
306+
.join("templates")
307+
.join("analysis.md"),
308+
analysis_tpl,
309+
args.force,
310+
)?;
311+
write_scaffold_file(
312+
&target
313+
.join("reports")
314+
.join("templates")
315+
.join("meta_synthesis.md"),
316+
synthesis_tpl,
317+
args.force,
318+
)?;
319+
write_scaffold_file(
320+
&target.join(".harness").join("runs").join(".gitkeep"),
321+
"",
322+
args.force,
323+
)?;
324+
write_scaffold_file(
325+
&target.join(".harness").join("datasets").join(".gitkeep"),
326+
"",
327+
args.force,
328+
)?;
329+
write_scaffold_file(&target.join(".env.example"), env_example, args.force)?;
330+
write_scaffold_file(&target.join(".env"), env_local, false)?;
331+
332+
if !args.skip_install {
333+
let argv = vec![
334+
"uv".to_owned(),
335+
"add".to_owned(),
336+
format!("research-harness @ git+{}", args.harness_git),
337+
];
338+
println!("Installing harness dependency: {}", format_command(&argv));
339+
let status = run_process_streaming_in_dir(&argv, &target)?;
340+
if !status.success() {
341+
bail!(
342+
"command `{}` failed with exit code {:?}",
343+
format_command(&argv),
344+
status.code()
345+
);
346+
}
347+
}
348+
349+
println!("Research project initialized at {}", target.display());
350+
println!(" project.yaml");
351+
println!(" configs/default.yaml");
352+
println!(" configs/thresholds.yaml");
353+
println!(" experiments/example.py");
354+
println!(" src/{}/bindings/", package_name);
355+
println!(" reports/templates/");
356+
println!(" .harness/ (project-local run state)");
357+
println!(" .env with HARNESS_HOME=.harness");
358+
359+
Ok(())
360+
}
361+
362+
fn normalize_package_name(input: &str) -> String {
363+
let mut out = String::new();
364+
let mut prev_was_sep = false;
365+
for ch in input.chars() {
366+
if ch.is_ascii_alphanumeric() || ch == '_' {
367+
out.push(ch.to_ascii_lowercase());
368+
prev_was_sep = false;
369+
} else if !prev_was_sep {
370+
out.push('_');
371+
prev_was_sep = true;
372+
}
373+
}
374+
let trimmed = out.trim_matches('_').to_string();
375+
let mut final_name = if trimmed.is_empty() {
376+
"research_project".to_owned()
377+
} else {
378+
trimmed
379+
};
380+
if final_name
381+
.chars()
382+
.next()
383+
.map(|c| c.is_ascii_digit())
384+
.unwrap_or(false)
385+
{
386+
final_name = format!("pkg_{}", final_name);
387+
}
388+
final_name
389+
}
390+
391+
fn write_scaffold_file(path: &Path, content: &str, force: bool) -> Result<()> {
392+
if path.exists() && !force {
393+
return Ok(());
394+
}
395+
if let Some(parent) = path.parent() {
396+
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
397+
}
398+
fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
399+
Ok(())
400+
}
190401

191402
fn handle_docker(state: &AppState, command: DockerCommand) -> Result<()> {
192403
match command {

docs/USAGE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ Global flags:
5353

5454
- `dev install [language]` – scaffold language-specific config files and run optional provisioning commands defined in config. Supports `--dry-run` to preview actions.
5555

56+
### Research Scaffolding
57+
58+
- `dev research init [directory] [--name <name>] [--package <pkg>] [--force] [--skip-install]`
59+
- Creates an isolated, project-local research scaffold with:
60+
- `project.yaml`
61+
- `configs/`, `experiments/`, `src/<package>/bindings/`
62+
- `reports/templates/`
63+
- `.harness/runs` and `.harness/datasets`
64+
- `.env` with `HARNESS_HOME=.harness`
65+
- Installs harness dependency via `uv add "research-harness @ git+<url>"` unless `--skip-install` is passed.
66+
5667
## Git Automation
5768

5869
- `dev git branch-create <name> [--from <base>] [--push] [--allow-dirty]`

0 commit comments

Comments
 (0)