//! `aura new` — scaffold a data-only research project (#241). //! //! `aura new` emits the data-only tier: a paths-only `Aura.toml`, a runnable //! starter blueprint over the std vocabulary, `.gitignore`, and `CLAUDE.md` — //! no crate, no build step. The scaffolder never builds and never loads (the //! project-load phase in `main()` sees a plain data-only project just like //! any other). //! //! The crate-scaffold templates (`ScaffoldSpec`/`scaffold_node_crate`/ //! `scaffold_spec`, cycle 0103, C16/C17) serve the native-node tier: `aura //! nodes new` attaches a node crate beside a project. //! //! Templates are raw strings with `__NS__` / `__NAME__` / `__NAME_SNAKE__` / //! `__ENGINE__` tokens substituted by plain `.replace()` — no format-string //! brace escaping in Rust template bodies. use std::path::{Path, PathBuf}; #[derive(Debug)] pub struct ScaffoldSpec { pub name: String, pub namespace: String, pub engine_root: PathBuf, pub target_dir: PathBuf, } fn valid_name(s: &str) -> bool { !s.is_empty() && !s.starts_with(|c: char| c.is_ascii_digit()) && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } fn valid_namespace(s: &str) -> bool { !s.is_empty() && !s.starts_with(|c: char| c.is_ascii_digit()) && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') } /// The name→namespace / name→blueprint-name mapping: dashes become /// underscores (mirrors cargo's crate-name normalization). pub fn snake(name: &str) -> String { name.replace('-', "_") } /// The compile-time default engine root: two ancestors above aura-cli's /// manifest dir (`/crates/aura-cli` → ``). Valid on the box the /// binary was built on; `scaffold_node_crate` refuses when it no longer /// exists. pub fn default_engine_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(2) .expect("aura-cli manifest dir has two ancestors") .to_path_buf() } /// The data-only project scaffold (#241): no crate, no build step. #[derive(Debug)] pub struct ProjectScaffoldSpec { pub name: String, pub target_dir: PathBuf, } /// Validate the argv-level inputs for the data-only project scaffold. `Err` /// is a usage fault — the caller exits 2 (C14 partition). pub fn project_scaffold_spec(name: &str, cwd: &Path) -> Result { if !valid_name(name) { return Err(format!( "invalid project name `{name}` (allowed: letters, digits, `_`, `-`; no leading digit)" )); } Ok(ProjectScaffoldSpec { name: name.to_string(), target_dir: cwd.join(name) }) } /// Validate the argv-level inputs. `Err` is a usage fault — the caller /// exits 2 (C14 partition). pub fn scaffold_spec( name: &str, engine_path: Option<&Path>, namespace: Option<&str>, cwd: &Path, ) -> Result { if !valid_name(name) { return Err(format!( "invalid project name `{name}` (allowed: letters, digits, `_`, `-`; no leading digit)" )); } let namespace = match namespace { Some(ns) if !valid_namespace(ns) => { return Err(format!( "invalid namespace `{ns}` (allowed: letters, digits, `_`; no leading digit)" )); } Some(ns) => ns.to_string(), None => snake(name), }; Ok(ScaffoldSpec { name: name.to_string(), namespace, engine_root: engine_path .map(Path::to_path_buf) .unwrap_or_else(default_engine_root), target_dir: cwd.join(name), }) } // ---------------------------------------------------------------- templates const CARGO_TOML: &str = r#"[package] name = "__NAME__" version = "0.1.0" edition = "2024" publish = false [lib] crate-type = ["cdylib"] [dependencies] aura-core = { path = "__ENGINE__/crates/aura-core" } # Standalone workspace root: never a member of an enclosing workspace. [workspace] "#; const AURA_TOML: &str = r#"# Static project context only (C17); paths only. [paths] runs = "runs" # data = "/path/to/archive" # the recorded-data root; defaults to the built-in path "#; const GITIGNORE_CRATE: &str = "/target\nCargo.lock\n"; const LIB_RS: &str = r#"//! __NAME__ — an aura research project: node logic + the exported vocabulary. //! //! Every node type this crate provides is registered in `vocabulary()` / //! `type_ids()` under the `__NS__::` namespace prefix; the `aura` host loads //! this cdylib per invocation and merges it with the std vocabulary. use aura_core::{ Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, }; /// One-input scalar gain: emits `input * factor`. Emits `None` until its /// input has a value (warm-up filter, C8). pub struct Scale { factor: f64, out: [Cell; 1], } impl Scale { pub fn new(factor: f64) -> Self { Self { factor, out: [Cell::from_f64(0.0)] } } // Replace `doc` below when renaming this sample node — it must keep // describing the node's own behaviour, not this scaffolding step. pub fn builder() -> PrimitiveBuilder { PrimitiveBuilder::new( "__NS__::Scale", NodeSchema { inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into(), }], output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }], // C29: every vocabulary entry ships a one-line meaning — the // doc field is required (E0063 without it) and gated at load. doc: "scalar gain: emits the input times the factor param", }, |p| Box::new(Scale::new(p[0].f64())), ) } } impl Node for Scale { fn lookbacks(&self) -> Vec { vec![1] } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { let w = ctx.f64_in(0); if w.is_empty() { return None; } self.out[0] = Cell::from_f64(w[0] * self.factor); Some(&self.out) } fn label(&self) -> String { format!("__NS__::Scale({})", self.factor) } } fn vocabulary(type_id: &str) -> Option { match type_id { "__NS__::Scale" => Some(Scale::builder()), _ => None, } } fn type_ids() -> &'static [&'static str] { &["__NS__::Scale"] } aura_core::aura_project! { namespace: "__NS__", vocabulary: vocabulary, type_ids: type_ids, } "#; const CLAUDE_MD_CRATE: &str = r#"# __NAME__ — an aura node crate Native node logic for an aura research project (extension-dev role). This crate is attached to its project via `[nodes]` in the project's `Aura.toml`. - Build: `cargo build` (the next `aura` invocation in the project loads the fresh dylib) - Each node is registered in `vocabulary()`/`type_ids()` under the `__NS__::` namespace prefix; blueprints reference nodes by that id. - This crate has no data-plane dependencies — nodes are pure stream transformers over read-only input windows. "#; const GITIGNORE_PROJECT: &str = "/runs\n"; const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#; const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data-only) This directory is an aura project: blueprints + research documents over the std vocabulary, anchored by `Aura.toml`. There is no crate and no build step. - Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream — the starter is closed; all params bound; bound values are defaults — `--override NODE.PARAM=VALUE` may override one for this run (#246)) - Campaign: `aura exec ` executes a registered campaign document (file or content id) — the multi-cell/axis surface - Axes: `aura graph introspect --params blueprints/signal.json` lists the open + bound-overridable axes, RAW `.` names (#328) - Native nodes: when the project needs its first project-specific node, `aura nodes new ` scaffolds a node crate beside this project and attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`). - Topology is data (`blueprints/*.json`); results land in `runs/`. - Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the continuously-tracked target position; a protective stop defines the risk unit R, and quality metrics are R-based. Entry signals become held state via the signal-side latch/edge-pulse idiom (see `aura graph introspect --vocabulary`). - Data plane: the research verbs are sugar over registered process/campaign documents — author them directly with `aura process` / `aura campaign`, growing one from a bare `{}` via `aura campaign introspect --unwired`. "#; /// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__` /// tokens exist in this template set (no namespace, no engine path). fn render_project(template: &str, name: &str) -> String { template .replace("__NAME_SNAKE__", &snake(name)) .replace("__NAME__", name) } fn render(template: &str, spec: &ScaffoldSpec) -> String { template .replace("__NAME_SNAKE__", &snake(&spec.name)) .replace("__NAME__", &spec.name) .replace("__NS__", &spec.namespace) .replace("__ENGINE__", &spec.engine_root.display().to_string()) } // --------------------------------------------------------------------- emit /// Emit a data-only project. `Err` is a runtime refusal (exit 1). All /// refusals fire before the first write. pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> { if spec.target_dir.exists() { return Err(format!("destination `{}` already exists", spec.target_dir.display())); } let write = |rel: &str, body: String| -> Result<(), String> { let path = spec.target_dir.join(rel); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir) .map_err(|e| format!("creating `{}`: {e}", dir.display()))?; } std::fs::write(&path, body).map_err(|e| format!("writing `{}`: {e}", path.display())) }; write("Aura.toml", AURA_TOML.to_string())?; write(".gitignore", GITIGNORE_PROJECT.to_string())?; write("blueprints/signal.json", render_project(SIGNAL_JSON_STD, &spec.name))?; write("CLAUDE.md", render_project(CLAUDE_MD_PROJECT, &spec.name))?; if inside_git_work_tree(&spec.target_dir) { return Ok(()); } match std::process::Command::new("git").arg("init").current_dir(&spec.target_dir).output() { Ok(o) if o.status.success() => { commit_all(&spec.target_dir, "aura new scaffold"); } _ => crate::diag::warning!("git init failed (project created without a repo)"), } Ok(()) } /// Emit a node crate (#241): the native half of the old single-tier scaffold /// — Cargo.toml + src/lib.rs + crate-scoped .gitignore + CLAUDE.md, no /// Aura.toml and no blueprints/ (those belong to the research project). pub fn scaffold_node_crate(spec: &ScaffoldSpec) -> Result<(), String> { if spec.target_dir.exists() { return Err(format!("destination `{}` already exists", spec.target_dir.display())); } if !spec.engine_root.join("crates/aura-core").is_dir() { return Err(format!( "engine checkout not found at `{}` — pass --engine-path ", spec.engine_root.display() )); } let write = |rel: &str, body: String| -> Result<(), String> { let path = spec.target_dir.join(rel); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir) .map_err(|e| format!("creating `{}`: {e}", dir.display()))?; } std::fs::write(&path, body).map_err(|e| format!("writing `{}`: {e}", path.display())) }; write("Cargo.toml", render(CARGO_TOML, spec))?; write(".gitignore", GITIGNORE_CRATE.to_string())?; write("src/lib.rs", render(LIB_RS, spec))?; write("CLAUDE.md", render(CLAUDE_MD_CRATE, spec))?; // Best-effort git init: a warning, never an error (cargo-new mirror). // Also the cargo-new mirror: inside an existing work tree the init is // SKIPPED silently — a nested .git is a gitlink/submodule hazard when the // scaffold lands in a tracked tree (#204). if inside_git_work_tree(&spec.target_dir) { return Ok(()); } match std::process::Command::new("git").arg("init").current_dir(&spec.target_dir).output() { Ok(o) if o.status.success() => { commit_all(&spec.target_dir, "aura nodes new scaffold"); } _ => crate::diag::warning!("git init failed (node crate created without a repo)"), } Ok(()) } /// Append the `[nodes]` pointer to a project's Aura.toml. Refuses when a /// `[nodes]` section already exists (single-crate iteration). pub fn append_nodes_pointer(project_root: &Path, pointer: &str) -> Result<(), String> { let path = project_root.join("Aura.toml"); let text = std::fs::read_to_string(&path) .map_err(|e| format!("reading `{}`: {e}", path.display()))?; if text.contains("[nodes]") { return Err( "Aura.toml already has a [nodes] section (multi-crate loading is not yet supported)" .to_string(), ); } std::fs::write(&path, format!("{text}\n[nodes]\ncrates = [\"{pointer}\"]\n")) .map_err(|e| format!("writing `{}`: {e}", path.display())) } /// Stage everything and make the initial commit in a freshly-`git init`ed /// scaffold, so `git rev-parse HEAD` resolves from the very first run /// (otherwise HEAD stays unborn and provenance stamping sees no commit). A /// committer identity is passed inline via `-c` so this works on a box with /// no global git config; best-effort — a failure is a warning, never fatal, /// and never runs when `git init` itself was skipped or failed. fn commit_all(dir: &std::path::Path, message: &str) { let add = std::process::Command::new("git") .args(["add", "-A"]) .current_dir(dir) .output(); if !matches!(add, Ok(o) if o.status.success()) { crate::diag::warning!("git add failed (scaffold created without an initial commit)"); return; } let commit = std::process::Command::new("git") .args([ "-c", "user.email=aura@localhost", "-c", "user.name=aura", "commit", "-q", "-m", message, ]) .current_dir(dir) .output(); if !matches!(commit, Ok(o) if o.status.success()) { crate::diag::warning!( "git commit failed (scaffold created without an initial commit)" ); } } /// Whether `dir` already sits inside a git work tree (`git rev-parse`, the /// same oracle cargo-new consults). A missing git binary or any failure reads /// as "not in a work tree" — the init stays best-effort either way. fn inside_git_work_tree(dir: &std::path::Path) -> bool { std::process::Command::new("git") .args(["rev-parse", "--is-inside-work-tree"]) .current_dir(dir) .output() .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true") .unwrap_or(false) } #[cfg(test)] mod tests { use super::*; fn spec_for(name: &str, ns: Option<&str>) -> ScaffoldSpec { scaffold_spec(name, None, ns, Path::new("/tmp/x")).expect("valid spec") } #[test] fn name_and_namespace_validation_table() { assert!(scaffold_spec("ger40-lab", None, None, Path::new("/t")).is_ok()); assert!(scaffold_spec("x_1", None, None, Path::new("/t")).is_ok()); for bad in ["", "bad/name", "9lives", "a b", "ä"] { let e = scaffold_spec(bad, None, None, Path::new("/t")).unwrap_err(); assert!(e.contains("invalid project name"), "{bad}: {e}"); } for bad_ns in ["", "with-dash", "9x", "a::b"] { let e = scaffold_spec("ok", None, Some(bad_ns), Path::new("/t")).unwrap_err(); assert!(e.contains("invalid namespace"), "{bad_ns}: {e}"); } } #[test] fn namespace_defaults_to_snake_name() { assert_eq!(spec_for("ger40-lab", None).namespace, "ger40_lab"); assert_eq!(spec_for("ger40-lab", Some("ger40")).namespace, "ger40"); assert_eq!(snake("a-b-c"), "a_b_c"); } #[test] fn default_engine_root_is_this_checkout() { assert!(default_engine_root().join("crates/aura-core").is_dir()); } #[test] fn templates_render_without_leftover_tokens() { // Crate-template set (native tier, via `ScaffoldSpec`). let spec = spec_for("demo-lab", None); for t in [CARGO_TOML, GITIGNORE_CRATE, LIB_RS, CLAUDE_MD_CRATE] { let out = render(t, &spec); assert!(!out.contains("__"), "unsubstituted token in: {out}"); } let lib = render(LIB_RS, &spec); assert!(lib.contains("\"demo_lab::Scale\"")); assert!(lib.contains("namespace: \"demo_lab\"")); let cargo = render(CARGO_TOML, &spec); assert!(cargo.contains("name = \"demo-lab\"")); assert!(cargo.contains("/crates/aura-core")); // Project-template set (data-only tier, via `render_project`). for t in [GITIGNORE_PROJECT, SIGNAL_JSON_STD, CLAUDE_MD_PROJECT] { let out = render_project(t, "demo-lab"); assert!(!out.contains("__"), "unsubstituted token in: {out}"); } let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab"); assert!(claude.contains("demo-lab")); // The CLAUDE.md quickstart targets the closed starter itself through // the surviving surface (#319): exec for both document classes, the // #246 override residue, and raw-namespace axis discovery (#328). assert!(claude.contains("blueprints/signal.json")); assert!(claude.contains("--override NODE.PARAM=VALUE")); assert!(claude.contains("graph introspect --params")); } /// #315: the scaffolded project CLAUDE.md teaches the execution semantics /// (bias as held target position, protective stop, R) and the document /// data plane — the two things the 2026-07-22 field agent had to discover /// forensically. #[test] fn project_claude_md_teaches_execution_semantics_and_the_data_plane() { let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab"); assert!(claude.contains("target position"), "names the held-target model: {claude}"); assert!(claude.contains("protective stop"), "names the stop that defines R: {claude}"); assert!(claude.contains("aura process"), "points at the document layer: {claude}"); assert!(claude.contains("introspect --unwired"), "names the bare-{{}} ramp: {claude}"); } /// #323: the authoring guide's S0 worked literal is byte-identical to the /// scaffold's `LIB_RS` template (rendered with the guide's `my_lab` /// namespace). The scaffold e2e tests compile that template for real, so /// this equality pin is a transitive compile guard — the guide literal /// can no longer drift silently from the schema (it broke against the /// required `NodeSchema.doc` once). #[test] fn guide_worked_example_matches_the_scaffold_template() { let guide = include_str!("../../../docs/authoring-guide.md"); let anchor = "### Worked example: `Scale`"; let after = &guide[guide.find(anchor).expect("guide keeps the S0 worked example")..]; let start = after.find("```rust\n").expect("worked example is a rust fence") + 8; let fence = &after[start..]; let fence = &fence[..fence.find("\n```").expect("fence closes") + 1]; let body = &LIB_RS[LIB_RS.find("use aura_core::{").expect("template body starts at the import")..]; let expected = body.replace("__NS__", "my_lab"); assert_eq!( fence, expected, "docs/authoring-guide.md S0 literal drifted from the compiled scaffold template" ); } /// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches /// the bound-param build closure. Its rendered `src/lib.rs` declares a param in /// the node schema and reads a bound value in the build closure (not the /// param-less `|_|` Identity the template shipped before) — the copyable /// template content a newcomer learns from directly. #[test] fn starter_node_declares_and_reads_a_bound_param() { let spec = spec_for("demo-lab", None); let lib = render(LIB_RS, &spec); // the node schema declares a bindable param... assert!( lib.contains("ParamSpec"), "starter node must declare a param in its schema, not `params: vec![]`: {lib}" ); // ...and the build closure reads that bound param (p[0]), not `|_|`. assert!( lib.contains("p[0].f64()"), "starter build closure must consume a bound param (p[0].f64()), not `|_|`: {lib}" ); } /// #241: the data-only starter blueprint references std vocabulary only — /// no project-namespaced (`::`) type id survives in the rendered JSON. /// It binds every param (runnable, #246: bound values are defaults an /// `--axis` may override, so this closed starter is also the sweep /// target). #[test] fn data_only_starter_blueprint_is_std_only() { let json = render_project(SIGNAL_JSON_STD, "demo-lab"); assert!(!json.contains("__"), "unsubstituted token: {json}"); assert!(!json.contains("::"), "project-namespaced id in std-only blueprint: {json}"); assert!(json.contains("\"name\":\"demo_lab_signal\"")); } #[test] fn scaffold_project_refuses_existing_destination() { let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join("aura-pscaf"); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(tmp.join("x")).unwrap(); let spec = project_scaffold_spec("x", &tmp).unwrap(); let e = scaffold_project(&spec).unwrap_err(); assert!(e.contains("already exists"), "{e}"); std::fs::remove_dir_all(&tmp).ok(); } #[test] fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() { let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join("aura-scaf"); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); // existing destination let mut spec = scaffold_spec("x", None, None, &tmp).unwrap(); std::fs::create_dir_all(tmp.join("x")).unwrap(); let e = scaffold_node_crate(&spec).unwrap_err(); assert!(e.contains("already exists"), "{e}"); // missing engine root std::fs::remove_dir_all(tmp.join("x")).unwrap(); spec.engine_root = PathBuf::from("/nonexistent-engine"); let e = scaffold_node_crate(&spec).unwrap_err(); assert!(e.contains("--engine-path"), "{e}"); std::fs::remove_dir_all(&tmp).ok(); } }