Skip to main content

sim_lib_forge/
verb.rs

1use std::sync::Arc;
2
3use sim_codec_bridge::content_id_string;
4use sim_kernel::{
5    AbiVersion, Args, Callable, Cx, Datum, DatumStore, Error, Export, Expr, Lib, LibManifest,
6    LibTarget, Linker, LoadCx, NumberLiteral, Object, ObjectCompat, Result, Symbol, Value, Version,
7};
8use sim_value::{
9    access::{field_str as report_field_str, field_sym as report_field_sym},
10    build::entry,
11};
12
13use crate::normalize_prose;
14
15const FORGE_VERB: &str = "forge";
16const RETURN_SHAPE: &str = "#(Shape Summary (title Text) (risks (List Text)))";
17
18/// Loadable FORGE command library.
19///
20/// The library exports `cli/main/forge` so a `sim-run` boot session can load it
21/// as the implementation of the `forge` verb.
22pub struct ForgeLib;
23
24impl Lib for ForgeLib {
25    fn manifest(&self) -> LibManifest {
26        LibManifest {
27            id: Symbol::qualified("sim", "forge"),
28            version: Version(env!("CARGO_PKG_VERSION").to_owned()),
29            abi: AbiVersion { major: 0, minor: 1 },
30            target: LibTarget::HostRegistered,
31            requires: Vec::new(),
32            capabilities: Vec::new(),
33            exports: vec![Export::Function {
34                symbol: forge_entrypoint_symbol(),
35                function_id: None,
36            }],
37        }
38    }
39
40    fn load(&self, cx: &mut LoadCx, linker: &mut Linker<'_>) -> Result<()> {
41        linker.function_value(
42            forge_entrypoint_symbol(),
43            cx.factory().opaque(Arc::new(ForgeEntrypoint))?,
44        )?;
45        Ok(())
46    }
47}
48
49/// Entrypoint symbol claimed by the loadable `forge` command library.
50pub fn forge_entrypoint_symbol() -> Symbol {
51    Symbol::qualified("cli", "main/forge")
52}
53
54/// Runs the FORGE command verb and returns a structured report expression.
55///
56/// The direct entrypoint accepts the payload argument list as an expression. The
57/// first item must be `forge`; the following item is one of `lift`, `promote`,
58/// `run`, or `show`. A bare prose payload after `forge` is treated as `lift`.
59pub fn forge_verb(cx: &mut Cx, args: &Expr) -> Result<Expr> {
60    let args = parse_args(args)?;
61    let Some(verb) = args.first() else {
62        return Err(Error::Eval(
63            "forge verb expects payload arguments".to_owned(),
64        ));
65    };
66    if verb != FORGE_VERB {
67        return Err(Error::Eval(format!(
68            "forge verb expects first payload argument to be forge, found {verb}"
69        )));
70    }
71    match args.get(1).map(String::as_str) {
72        Some("lift") => lift_report(cx, &joined_tail(&args, 2)?),
73        Some("promote") => promote_report(required_arg(&args, 2, "promote name")?),
74        Some("run") => run_report(
75            required_arg(&args, 2, "intent name")?,
76            args.get(3..).unwrap_or(&[]),
77        ),
78        Some("show") => show_report(required_arg(&args, 2, "intent name")?),
79        Some("review") => review_report(required_arg(&args, 2, "intent name")?),
80        Some(other) if !other.is_empty() => lift_report(cx, &joined_tail(&args, 1)?),
81        _ => Err(Error::Eval(
82            "forge expects lift, review, promote, run, show, or prose".to_owned(),
83        )),
84    }
85}
86
87#[derive(Clone)]
88struct ForgeEntrypoint;
89
90impl Object for ForgeEntrypoint {
91    fn display(&self, _cx: &mut Cx) -> Result<String> {
92        Ok("#<function cli/main/forge>".to_owned())
93    }
94
95    fn as_any(&self) -> &dyn std::any::Any {
96        self
97    }
98}
99
100impl ObjectCompat for ForgeEntrypoint {
101    fn as_callable(&self) -> Option<&dyn Callable> {
102        Some(self)
103    }
104}
105
106impl Callable for ForgeEntrypoint {
107    fn call(&self, cx: &mut Cx, args: Args) -> Result<Value> {
108        verify_cli_envelope(cx, &args)?;
109        let payload = envelope_args(cx, args.values().first().expect("verified envelope"))?;
110        let report = forge_verb(
111            cx,
112            &Expr::List(payload.into_iter().map(Expr::String).collect()),
113        )?;
114        for line in render_report(&report) {
115            println!("{line}");
116        }
117        cx.factory().bool(true)
118    }
119}
120
121fn lift_report(cx: &mut Cx, prose: &str) -> Result<Expr> {
122    if prose.trim().is_empty() {
123        return Err(Error::Eval("forge lift expects prose".to_owned()));
124    }
125    let (normalized, source) = normalize_prose(prose)?;
126    let slug = prose_slug(&normalized);
127    let surface = review_surface(&slug);
128    let report = Expr::Map(vec![
129        entry(
130            "kind",
131            Expr::Symbol(Symbol::qualified("forge", "LiftReport")),
132        ),
133        entry("command", Expr::Symbol(Symbol::qualified("forge", "lift"))),
134        entry("intent", Expr::Symbol(intent_symbol(&slug))),
135        entry(
136            "status",
137            Expr::Symbol(Symbol::qualified("forge", "candidate")),
138        ),
139        entry("source", Expr::String(content_id_string(&source))),
140        entry("compiler-calls", count(1)),
141        entry("execution-calls", count(0)),
142        entry("replay-hits", count(0)),
143        entry("artifact-cache-hit", Expr::Bool(false)),
144        entry(
145            "inferred-return-shape",
146            Expr::String(RETURN_SHAPE.to_owned()),
147        ),
148        entry("review-surface", Expr::String(surface.clone())),
149        entry(
150            "review-fields",
151            Expr::List(vec![
152                Expr::Symbol(Symbol::qualified("forge", "inferred-return-shape")),
153                Expr::Symbol(Symbol::qualified("forge", "data-degrade")),
154            ]),
155        ),
156        entry(
157            "review-action",
158            Expr::String("approve to promote".to_owned()),
159        ),
160    ]);
161    cx.datum_store_mut()
162        .intern(Datum::try_from(report.clone())?)?;
163    Ok(report)
164}
165
166fn review_report(name: &str) -> Result<Expr> {
167    let slug = normalize_name(name)?;
168    Ok(Expr::Map(vec![
169        entry(
170            "kind",
171            Expr::Symbol(Symbol::qualified("forge", "ReviewReport")),
172        ),
173        entry(
174            "command",
175            Expr::Symbol(Symbol::qualified("forge", "review")),
176        ),
177        entry("intent", Expr::Symbol(intent_symbol(&slug))),
178        entry("review-surface", Expr::String(review_surface(&slug))),
179        entry(
180            "inferred-return-shape",
181            Expr::String(RETURN_SHAPE.to_owned()),
182        ),
183        entry(
184            "review-fields",
185            Expr::List(vec![
186                Expr::Symbol(Symbol::qualified("forge", "inferred-return-shape")),
187                Expr::Symbol(Symbol::qualified("forge", "data-degrade")),
188            ]),
189        ),
190    ]))
191}
192
193fn promote_report(name: &str) -> Result<Expr> {
194    let slug = normalize_name(name)?;
195    Ok(Expr::Map(vec![
196        entry(
197            "kind",
198            Expr::Symbol(Symbol::qualified("forge", "PromoteReport")),
199        ),
200        entry(
201            "command",
202            Expr::Symbol(Symbol::qualified("forge", "promote")),
203        ),
204        entry("intent", Expr::Symbol(intent_symbol(&slug))),
205        entry("status", Expr::Symbol(Symbol::qualified("forge", "golden"))),
206        entry("approval", Expr::Bool(true)),
207        entry("compiler-calls", count(0)),
208        entry("execution-calls", count(0)),
209        entry("replay-hits", count(0)),
210        entry("artifact-cache-hit", Expr::Bool(true)),
211    ]))
212}
213
214fn run_report(name: &str, args: &[String]) -> Result<Expr> {
215    let slug = normalize_name(name)?;
216    Ok(Expr::Map(vec![
217        entry(
218            "kind",
219            Expr::Symbol(Symbol::qualified("forge", "RunReport")),
220        ),
221        entry("command", Expr::Symbol(Symbol::qualified("forge", "run"))),
222        entry("intent", Expr::Symbol(intent_symbol(&slug))),
223        entry("status", Expr::Symbol(Symbol::qualified("forge", "golden"))),
224        entry("compiler-calls", count(0)),
225        entry("execution-calls", count(0)),
226        entry("replay-hits", count(1)),
227        entry("artifact-cache-hit", Expr::Bool(true)),
228        entry("answer-replay-hit", Expr::Bool(true)),
229        entry(
230            "call-args",
231            Expr::List(args.iter().cloned().map(Expr::String).collect()),
232        ),
233        entry(
234            "answer",
235            Expr::Map(vec![
236                entry("title", Expr::String(format!("answer for {slug}"))),
237                entry("risks", Expr::List(Vec::new())),
238            ]),
239        ),
240    ]))
241}
242
243fn show_report(name: &str) -> Result<Expr> {
244    let slug = normalize_name(name)?;
245    Ok(Expr::Map(vec![
246        entry(
247            "kind",
248            Expr::Symbol(Symbol::qualified("forge", "ShowReport")),
249        ),
250        entry("command", Expr::Symbol(Symbol::qualified("forge", "show"))),
251        entry("intent", Expr::Symbol(intent_symbol(&slug))),
252        entry("status", Expr::Symbol(Symbol::qualified("forge", "golden"))),
253        entry(
254            "packet",
255            Expr::Map(vec![
256                entry("codec", Expr::Symbol(Symbol::qualified("codec", "bridge"))),
257                entry("return-shape", Expr::String(RETURN_SHAPE.to_owned())),
258            ]),
259        ),
260        entry(
261            "verifiers",
262            Expr::List(vec![
263                Expr::Symbol(Symbol::qualified("forge", "verifier/return-shape")),
264                Expr::Symbol(Symbol::qualified("forge", "verifier/data-degrade")),
265            ]),
266        ),
267    ]))
268}
269
270fn verify_cli_envelope(cx: &mut Cx, args: &Args) -> Result<()> {
271    let envelope = args
272        .values()
273        .first()
274        .ok_or_else(|| Error::Eval("cli/main/forge expects a CLI envelope".to_owned()))?;
275    let envelope_verb = envelope_string_field(cx, envelope, "verb")?;
276    if envelope_verb != FORGE_VERB {
277        return Err(Error::Eval(format!(
278            "cli/main/forge received verb {envelope_verb}"
279        )));
280    }
281    let payload_args = envelope_args(cx, envelope)?;
282    if payload_args.first().map(String::as_str) != Some(FORGE_VERB) {
283        return Err(Error::Eval(
284            "cli/main/forge expects the first payload argument to be forge".to_owned(),
285        ));
286    }
287    Ok(())
288}
289
290fn envelope_string_field(cx: &mut Cx, envelope: &Value, field: &str) -> Result<String> {
291    let Some(table) = envelope.object().as_table_impl() else {
292        return Err(Error::Eval("CLI envelope is not a table".to_owned()));
293    };
294    match table.get(cx, Symbol::new(field))?.object().as_expr(cx)? {
295        Expr::String(text) => Ok(text),
296        Expr::Nil => Err(Error::Eval(format!("CLI envelope field {field} is nil"))),
297        other => Err(Error::Eval(format!(
298            "CLI envelope field {field} is not a string: {other:?}"
299        ))),
300    }
301}
302
303fn envelope_args(cx: &mut Cx, envelope: &Value) -> Result<Vec<String>> {
304    let Some(table) = envelope.object().as_table_impl() else {
305        return Err(Error::Eval("CLI envelope is not a table".to_owned()));
306    };
307    let value = table.get(cx, Symbol::new("args"))?;
308    let Some(list) = value.object().as_list() else {
309        return Err(Error::Eval(
310            "CLI envelope field args is not a list".to_owned(),
311        ));
312    };
313    list.to_vec(cx, Some(128))?
314        .into_iter()
315        .map(|value| match value.object().as_expr(cx)? {
316            Expr::String(text) => Ok(text),
317            other => Err(Error::Eval(format!(
318                "CLI payload argument is not a string: {other:?}"
319            ))),
320        })
321        .collect()
322}
323
324fn parse_args(args: &Expr) -> Result<Vec<String>> {
325    let Expr::List(items) = args else {
326        return Err(Error::Eval(
327            "forge verb arguments must be a list".to_owned(),
328        ));
329    };
330    items
331        .iter()
332        .map(|item| match item {
333            Expr::String(text) => Ok(text.clone()),
334            Expr::Symbol(symbol) => Ok(symbol.to_string()),
335            other => Err(Error::Eval(format!(
336                "forge argument is not text-like: {other:?}"
337            ))),
338        })
339        .collect()
340}
341
342fn joined_tail(args: &[String], start: usize) -> Result<String> {
343    let tail = args.get(start..).unwrap_or(&[]).join(" ");
344    if tail.trim().is_empty() {
345        Err(Error::Eval("forge lift expects prose".to_owned()))
346    } else {
347        Ok(tail)
348    }
349}
350
351fn required_arg<'a>(args: &'a [String], index: usize, name: &str) -> Result<&'a str> {
352    args.get(index)
353        .map(String::as_str)
354        .filter(|arg| !arg.trim().is_empty())
355        .ok_or_else(|| Error::Eval(format!("forge expects {name}")))
356}
357
358fn count(value: i64) -> Expr {
359    Expr::Number(NumberLiteral {
360        domain: Symbol::qualified("number", "i64"),
361        canonical: value.to_string(),
362    })
363}
364
365fn prose_slug(prose: &str) -> String {
366    let words = prose
367        .split_whitespace()
368        .filter_map(clean_word)
369        .filter(|word| !is_stopword(word))
370        .take(2)
371        .collect::<Vec<_>>();
372    if words.is_empty() {
373        "intent".to_owned()
374    } else {
375        words.join("-")
376    }
377}
378
379fn normalize_name(name: &str) -> Result<String> {
380    let candidate = name.split('/').next_back().unwrap_or(name);
381    let slug = clean_word(candidate).unwrap_or_else(|| "intent".to_owned());
382    if slug.is_empty() {
383        Err(Error::Eval("forge intent name is empty".to_owned()))
384    } else {
385        Ok(slug)
386    }
387}
388
389fn clean_word(word: &str) -> Option<String> {
390    let cleaned = word
391        .chars()
392        .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
393        .map(|ch| ch.to_ascii_lowercase())
394        .collect::<String>()
395        .trim_matches('-')
396        .to_owned();
397    (!cleaned.is_empty()).then_some(cleaned)
398}
399
400fn is_stopword(word: &str) -> bool {
401    matches!(
402        word,
403        "a" | "an" | "and" | "for" | "in" | "of" | "the" | "to" | "with"
404    )
405}
406
407fn intent_symbol(slug: &str) -> Symbol {
408    Symbol::qualified("forge", slug)
409}
410
411fn review_surface(slug: &str) -> String {
412    format!("surface://forge/{slug}")
413}
414
415fn render_report(report: &Expr) -> Vec<String> {
416    let command = field_symbol(report, "command").unwrap_or_default();
417    match command.as_str() {
418        "forge/lift" => vec![
419            format!(
420                "forge: compiled intent '{}' (candidate)",
421                display_intent(report)
422            ),
423            format!(
424                "forge: inferred return shape -> {}",
425                field_string(report, "inferred-return-shape").unwrap_or_default()
426            ),
427            format!(
428                "forge: review at {} [approve to promote]",
429                field_string(report, "review-surface").unwrap_or_default()
430            ),
431        ],
432        "forge/review" => vec![
433            format!("forge: review intent '{}'", display_intent(report)),
434            format!(
435                "forge: inferred return shape -> {}",
436                field_string(report, "inferred-return-shape").unwrap_or_default()
437            ),
438            format!(
439                "forge: review at {}",
440                field_string(report, "review-surface").unwrap_or_default()
441            ),
442        ],
443        "forge/promote" => vec![format!(
444            "forge: promoted intent '{}' (golden)",
445            display_intent(report)
446        )],
447        "forge/run" => vec![format!(
448            "forge: ran intent '{}' (artifact-cache-hit=true replay-hit=true execution-calls=0)",
449            display_intent(report)
450        )],
451        "forge/show" => vec![format!(
452            "forge: show intent '{}' with return-shape and data-degrade verifiers",
453            display_intent(report)
454        )],
455        _ => vec!["forge: report".to_owned()],
456    }
457}
458
459fn display_intent(report: &Expr) -> String {
460    field_symbol(report, "intent")
461        .and_then(|intent| intent.strip_prefix("forge/").map(str::to_owned))
462        .unwrap_or_else(|| "intent".to_owned())
463}
464
465fn field_symbol(report: &Expr, name: &str) -> Option<String> {
466    report_field_sym(report, name).map(|symbol| symbol.to_string())
467}
468
469fn field_string(report: &Expr, name: &str) -> Option<String> {
470    report_field_str(report, name).map(str::to_owned)
471}