noether-cli 0.2.1

Noether CLI: ACLI-compliant command-line interface for stage management, composition graph execution, and LLM-powered compose
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
use crate::output::{acli_error, acli_ok};
use noether_core::effects::{Effect, EffectSet};
use noether_core::stage::{verify_stage_signature, StageLifecycle};
use noether_engine::index::SemanticIndex;
use noether_engine::llm::{LlmConfig, LlmProvider, Message};
use noether_store::StageStore;
use serde_json::json;

pub fn cmd_stats(store: &dyn StageStore, index: &SemanticIndex) {
    let stats = store.stats();
    let near_duplicate_pairs = index.find_near_duplicates(0.92).len();
    println!(
        "{}",
        acli_ok(json!({
            "total": stats.total,
            "by_lifecycle": stats.by_lifecycle,
            "by_effect": stats.by_effect,
            "near_duplicate_pairs": near_duplicate_pairs,
            "dedup_rate_pct": if stats.total > 0 {
                (near_duplicate_pairs * 2 * 100) / stats.total
            } else {
                0
            },
        }))
    );
}

pub fn cmd_retro(
    store: &mut dyn StageStore,
    index: &SemanticIndex,
    dry_run: bool,
    apply: bool,
    threshold: f32,
) {
    let pairs = index.find_near_duplicates(threshold);

    if pairs.is_empty() {
        println!(
            "{}",
            acli_ok(json!({
                "threshold": threshold,
                "near_duplicate_pairs": 0,
                "pairs": [],
                "action": "none",
                "message": "Store is clean — no near-duplicate stages found.",
            }))
        );
        return;
    }

    // Resolve descriptions for display
    let pair_details: Vec<serde_json::Value> = pairs
        .iter()
        .map(|(a, b, sim)| {
            let desc_a = store
                .get(a)
                .ok()
                .flatten()
                .map(|s| s.description.clone())
                .unwrap_or_default();
            let desc_b = store
                .get(b)
                .ok()
                .flatten()
                .map(|s| s.description.clone())
                .unwrap_or_default();
            json!({
                "stage_a": { "id": a.0, "description": desc_a },
                "stage_b": { "id": b.0, "description": desc_b },
                "similarity": sim,
                "recommendation": "deprecate stage_b, use stage_a as canonical",
            })
        })
        .collect();

    if dry_run || !apply {
        println!(
            "{}",
            acli_ok(json!({
                "threshold": threshold,
                "near_duplicate_pairs": pair_details.len(),
                "pairs": pair_details,
                "action": "dry-run",
                "message": format!(
                    "Found {} near-duplicate pair(s). Run with --apply to deprecate.",
                    pair_details.len()
                ),
            }))
        );
        return;
    }

    // --apply: deprecate stage_b in favour of stage_a for each pair
    // We only process pairs where stage_b is not already deprecated/tombstoned.
    let mut deprecated_count = 0;
    let mut skipped_count = 0;
    let mut errors: Vec<String> = Vec::new();

    for (a, b, _sim) in &pairs {
        let lifecycle_b = store.get(b).ok().flatten().map(|s| s.lifecycle.clone());

        match lifecycle_b {
            Some(noether_core::stage::StageLifecycle::Active) => {
                let deprecated = noether_core::stage::StageLifecycle::Deprecated {
                    successor_id: a.clone(),
                };
                match store.update_lifecycle(b, deprecated) {
                    Ok(_) => {
                        deprecated_count += 1;
                    }
                    Err(e) => errors.push(format!("failed to deprecate {}: {e}", b.0)),
                }
            }
            _ => {
                skipped_count += 1;
            }
        }
    }

    if !errors.is_empty() {
        eprintln!(
            "{}",
            acli_error(&format!(
                "retro completed with {} error(s): {}",
                errors.len(),
                errors.join("; ")
            ))
        );
    }

    println!(
        "{}",
        acli_ok(json!({
            "threshold": threshold,
            "near_duplicate_pairs": pairs.len(),
            "deprecated": deprecated_count,
            "skipped": skipped_count,
            "errors": errors,
            "action": "applied",
            "message": format!(
                "Deprecated {} stage(s), skipped {} (already inactive).",
                deprecated_count, skipped_count
            ),
        }))
    );
}

/// Infer and record effects for all stages currently marked `Unknown`.
///
/// In Noether's content-addressed model, changing a stage's declared effects
/// changes its identity hash. This command therefore:
/// 1. Identifies all Active stages with Unknown effects that have implementation code.
/// 2. Uses the LLM to infer the likely effects from description + code snippet.
/// 3. In `--dry-run` mode: reports the plan, no mutations.
/// 4. Without `--dry-run`: creates a new stage with inferred effects (new hash)
///    and deprecates the original, preserving the full audit chain.
pub fn cmd_migrate_effects(store: &mut dyn StageStore, llm: &dyn LlmProvider, dry_run: bool) {
    let candidates: Vec<_> = store
        .list(None)
        .into_iter()
        .filter(|s| {
            matches!(s.lifecycle, StageLifecycle::Active)
                && s.signature.effects.is_unknown()
                && s.implementation_code.is_some()
        })
        .cloned()
        .collect();

    if candidates.is_empty() {
        println!(
            "{}",
            acli_ok(json!({
                "candidates": 0,
                "action": if dry_run { "dry-run" } else { "none" },
                "message": "No Unknown-effects stages with implementation code found.",
                "migrations": [],
            }))
        );
        return;
    }

    let mut migrations: Vec<serde_json::Value> = Vec::new();

    for stage in &candidates {
        let code = stage.implementation_code.as_deref().unwrap_or("");
        let inferred = infer_effects_with_llm(llm, &stage.description, code);

        migrations.push(json!({
            "stage_id": stage.id.0,
            "description": stage.description,
            "inferred_effects": inferred.iter().map(|e| format!("{e:?}")).collect::<Vec<_>>(),
            "applied": !dry_run,
        }));

        if dry_run {
            continue;
        }

        // Build new stage with inferred effects.
        // We keep all metadata identical except the effects field in the signature.
        let mut new_stage = stage.clone();
        new_stage.signature.effects = inferred;
        // Recompute ID for the new signature.
        match noether_core::stage::compute_stage_id(&new_stage.signature) {
            Ok(new_id) => {
                new_stage.id = new_id.clone();
                // Re-sign with the same signer key if present — we can't do that
                // without the private key, so clear signatures and let the caller
                // re-sign via `stage add` if needed.
                new_stage.ed25519_signature = None;
                new_stage.signer_public_key = None;

                // Insert the updated stage.
                if let Err(e) = store.put(new_stage) {
                    eprintln!(
                        "Warning: failed to insert migrated stage for {}: {e}",
                        stage.id.0
                    );
                    continue;
                }

                // Deprecate the original with the new stage as successor.
                let deprecated = StageLifecycle::Deprecated {
                    successor_id: new_id,
                };
                if let Err(e) = store.update_lifecycle(&stage.id, deprecated) {
                    eprintln!(
                        "Warning: failed to deprecate original {} after migration: {e}",
                        stage.id.0
                    );
                }
            }
            Err(e) => {
                eprintln!(
                    "Warning: could not compute new stage ID for {}: {e}",
                    stage.id.0
                );
            }
        }
    }

    println!(
        "{}",
        acli_ok(json!({
            "candidates": candidates.len(),
            "action": if dry_run { "dry-run" } else { "applied" },
            "message": if dry_run {
                format!(
                    "Would migrate {} stage(s). Run without --dry-run to apply.",
                    candidates.len()
                )
            } else {
                format!("Migrated {} stage(s).", candidates.len())
            },
            "migrations": migrations,
        }))
    );
}

/// Ask the LLM to classify effects from a stage description and code snippet.
/// Falls back to `Unknown` (empty) on any error.
fn infer_effects_with_llm(llm: &dyn LlmProvider, description: &str, code: &str) -> EffectSet {
    let code_snippet = if code.len() > 800 { &code[..800] } else { code };

    let prompt = format!(
        r#"You are classifying the side-effects of a Noether stage.

Stage description: {description}

Implementation (first 800 chars):
```
{code_snippet}
```

Reply with a JSON array containing ONLY the applicable effects from this list:
- "Pure"             — deterministic, no side-effects, safe to cache forever
- "Network"          — makes HTTP or socket calls
- "Llm"              — calls a language model
- "Fallible"         — may fail for non-type reasons (network errors, invalid input, etc.)
- "NonDeterministic" — same input may give different output (implies not Pure)

Reply ONLY with a JSON array, e.g.: ["Pure"] or ["Network","Fallible"] or ["Llm","NonDeterministic"].
No explanation, no markdown, just the JSON array."#
    );

    let messages = vec![Message::user(prompt)];
    let cfg = LlmConfig::default();
    match llm.complete(&messages, &cfg) {
        Ok(response) => parse_effects_response(&response),
        Err(e) => {
            eprintln!("Warning: LLM effect inference failed: {e}. Keeping Unknown.");
            EffectSet::default()
        }
    }
}

fn parse_effects_response(response: &str) -> EffectSet {
    // Extract the JSON array from the response (handle markdown fences etc.)
    let trimmed = response.trim();
    let json_start = trimmed.find('[').unwrap_or(0);
    let json_end = trimmed.rfind(']').map(|i| i + 1).unwrap_or(trimmed.len());
    let json_str = &trimmed[json_start..json_end];

    let names: Vec<String> = serde_json::from_str(json_str).unwrap_or_default();
    let effects: Vec<Effect> = names
        .iter()
        .filter_map(|name| match name.as_str() {
            "Pure" => Some(Effect::Pure),
            "Network" => Some(Effect::Network),
            "Fallible" => Some(Effect::Fallible),
            "NonDeterministic" => Some(Effect::NonDeterministic),
            "Llm" => Some(Effect::Llm {
                model: "unknown".into(),
            }),
            // FsRead / FsWrite are Capabilities, not Effects — silently ignore.
            _ => None,
        })
        .collect();

    if effects.is_empty() {
        EffectSet::unknown()
    } else {
        EffectSet::new(effects)
    }
}

/// Surface near-duplicate stages above `threshold` cosine similarity.
///
/// Unlike `retro` (which deprecates), `dedup` tombstones the lower-relevance
/// stage in each pair — permanent removal, not soft deprecation.
/// A stage is "lower" if it has fewer examples or was registered later (shorter ID by alpha).
pub fn cmd_dedup(store: &mut dyn StageStore, index: &SemanticIndex, threshold: f32, apply: bool) {
    let pairs = index.find_near_duplicates(threshold);

    if pairs.is_empty() {
        println!(
            "{}",
            acli_ok(json!({
                "threshold": threshold,
                "pairs_found": 0,
                "action": if apply { "none" } else { "dry-run" },
                "message": format!("No near-duplicate pairs above {:.0}% similarity.", threshold * 100.0),
            }))
        );
        return;
    }

    let pair_details: Vec<serde_json::Value> = pairs
        .iter()
        .map(|(a, b, sim)| {
            let stage_a = store.get(a).ok().flatten();
            let stage_b = store.get(b).ok().flatten();
            let (desc_a, ex_a) = stage_a
                .as_ref()
                .map(|s| (s.description.clone(), s.examples.len()))
                .unwrap_or_default();
            let (desc_b, ex_b) = stage_b
                .as_ref()
                .map(|s| (s.description.clone(), s.examples.len()))
                .unwrap_or_default();
            // The stage with fewer examples is the "redundant" one.
            // If tied, prefer keeping the alphabetically smaller ID (stable, deterministic).
            let (keep_id, remove_id, keep_desc, remove_desc) = if ex_a >= ex_b && (ex_a != ex_b || a.0 <= b.0) {
                (a, b, &desc_a, &desc_b)
            } else {
                (b, a, &desc_b, &desc_a)
            };
            json!({
                "keep":   { "id": &keep_id.0[..8.min(keep_id.0.len())], "description": keep_desc, "examples": ex_a.max(ex_b) },
                "remove": { "id": &remove_id.0[..8.min(remove_id.0.len())], "description": remove_desc, "examples": ex_a.min(ex_b) },
                "similarity": sim,
            })
        })
        .collect();

    if !apply {
        println!(
            "{}",
            acli_ok(json!({
                "threshold": threshold,
                "pairs_found": pair_details.len(),
                "action": "dry-run",
                "pairs": pair_details,
                "message": format!(
                    "Found {} near-duplicate pair(s). Run with --apply to deprecate the redundant stages (with successor_id pointing to the kept stage).",
                    pair_details.len()
                ),
            }))
        );
        return;
    }

    let mut tombstoned = 0;
    let mut skipped = 0;
    let mut errors: Vec<String> = Vec::new();

    for (a, b, _sim) in &pairs {
        let stage_a = store.get(a).ok().flatten();
        let stage_b = store.get(b).ok().flatten();
        let (ex_a, ex_b) = (
            stage_a.as_ref().map(|s| s.examples.len()).unwrap_or(0),
            stage_b.as_ref().map(|s| s.examples.len()).unwrap_or(0),
        );
        let (keep_id, remove_id) = if ex_a >= ex_b && (ex_a != ex_b || a.0 <= b.0) {
            (a, b)
        } else {
            (b, a)
        };

        let lifecycle = store
            .get(remove_id)
            .ok()
            .flatten()
            .map(|s| s.lifecycle.clone());
        match lifecycle {
            Some(noether_core::stage::StageLifecycle::Active) => {
                // Deprecate (not tombstone) so graphs referencing the old ID
                // get a clear error pointing to the replacement stage.
                let new_lc = noether_core::stage::StageLifecycle::Deprecated {
                    successor_id: keep_id.clone(),
                };
                match store.update_lifecycle(remove_id, new_lc) {
                    Ok(_) => tombstoned += 1,
                    Err(e) => {
                        errors.push(format!("could not deprecate {}: {e}", &remove_id.0[..8]))
                    }
                }
            }
            _ => skipped += 1,
        }
    }

    println!(
        "{}",
        acli_ok(json!({
            "threshold": threshold,
            "pairs_found": pairs.len(),
            "tombstoned": tombstoned,
            "skipped": skipped,
            "errors": errors,
            "action": "applied",
            "message": format!("Deprecated {} duplicate stage(s) (with successor_id), skipped {} (already inactive).", tombstoned, skipped),
        }))
    );
}

/// Audit store health: check signatures, lifecycle integrity, example coverage, and orphan stages.
///
/// Returns a structured report with categories of issues:
/// - **unsigned**: Active non-stdlib stages without an Ed25519 signature.
/// - **invalid_signature**: Stages whose signature fails cryptographic verification.
/// - **no_examples**: Active stages with no usage examples (reduces semantic search quality).
/// - **unknown_effects**: Active stages whose effects are Unknown (can't be safely cached or planned).
/// - **deprecated_no_successor**: Deprecated stages with no valid successor stage in store.
/// - **tombstoned_active**: (reserved) tombstone invariant check placeholder.
///
/// This command is read-only — it never modifies the store.
pub fn cmd_health(store: &dyn StageStore) {
    let all_stages = store.list(None);
    let stdlib_ids: std::collections::HashSet<String> = noether_core::stdlib::load_stdlib()
        .iter()
        .map(|s| s.id.0.clone())
        .collect();

    let mut unsigned: Vec<serde_json::Value> = Vec::new();
    let mut invalid_sig: Vec<serde_json::Value> = Vec::new();
    let mut no_examples: Vec<serde_json::Value> = Vec::new();
    let mut unknown_effects: Vec<serde_json::Value> = Vec::new();
    let mut deprecated_no_successor: Vec<serde_json::Value> = Vec::new();

    for stage in &all_stages {
        let is_stdlib = stdlib_ids.contains(&stage.id.0);
        let short_id = &stage.id.0[..8.min(stage.id.0.len())];

        match &stage.lifecycle {
            StageLifecycle::Active => {
                // 1. Unsigned non-stdlib stages
                if !is_stdlib && stage.ed25519_signature.is_none() {
                    unsigned.push(json!({
                        "id": short_id,
                        "description": stage.description,
                        "fix": "re-add with `noether stage add` to sign with local author key"
                    }));
                }

                // 2. Invalid signatures
                if let (Some(sig), Some(pub_key)) =
                    (&stage.ed25519_signature, &stage.signer_public_key)
                {
                    match verify_stage_signature(&stage.id, sig, pub_key) {
                        Ok(false) => {
                            invalid_sig.push(json!({
                                "id": short_id,
                                "description": stage.description,
                                "signer": &pub_key[..16.min(pub_key.len())],
                                "fix": "stage may have been tampered with; tombstone and re-register"
                            }));
                        }
                        Err(e) => {
                            invalid_sig.push(json!({
                                "id": short_id,
                                "description": stage.description,
                                "error": e.to_string(),
                                "fix": "signature could not be decoded; tombstone and re-register"
                            }));
                        }
                        Ok(true) => {} // valid
                    }
                }

                // 3. No examples
                if stage.examples.is_empty() {
                    no_examples.push(json!({
                        "id": short_id,
                        "description": stage.description,
                        "fix": "add at least one example to improve semantic search quality"
                    }));
                }

                // 4. Unknown effects
                if stage.signature.effects.is_unknown() {
                    unknown_effects.push(json!({
                        "id": short_id,
                        "description": stage.description,
                        "fix": "run `noether store migrate-effects` to infer effects via LLM"
                    }));
                }
            }
            StageLifecycle::Deprecated { successor_id } => {
                // 5. Deprecated pointing to a non-existent or tombstoned successor
                let successor_ok = store
                    .get(successor_id)
                    .ok()
                    .flatten()
                    .map(|s| !matches!(s.lifecycle, StageLifecycle::Tombstone))
                    .unwrap_or(false);
                if !successor_ok {
                    deprecated_no_successor.push(json!({
                        "id": short_id,
                        "description": stage.description,
                        "successor_id": &successor_id.0[..8.min(successor_id.0.len())],
                        "fix": "update successor_id to a valid active stage, or tombstone this stage"
                    }));
                }
            }
            StageLifecycle::Draft | StageLifecycle::Tombstone => {}
        }
    }

    let total_issues = unsigned.len()
        + invalid_sig.len()
        + no_examples.len()
        + unknown_effects.len()
        + deprecated_no_successor.len();

    let status = if invalid_sig.is_empty() && unsigned.is_empty() {
        "healthy"
    } else {
        "needs_attention"
    };

    println!(
        "{}",
        acli_ok(json!({
            "status": status,
            "total_stages": all_stages.len(),
            "total_issues": total_issues,
            "categories": {
                "unsigned": { "count": unsigned.len(), "items": unsigned },
                "invalid_signature": { "count": invalid_sig.len(), "items": invalid_sig },
                "no_examples": { "count": no_examples.len(), "items": no_examples },
                "unknown_effects": { "count": unknown_effects.len(), "items": unknown_effects },
                "deprecated_no_successor": { "count": deprecated_no_successor.len(), "items": deprecated_no_successor },
            },
            "summary": if total_issues == 0 {
                "Store is healthy. All signatures valid, all active stages have examples.".to_string()
            } else {
                format!(
                    "{} issue(s) found: {} unsigned, {} invalid signature(s), {} missing examples, {} unknown effects, {} broken deprecations.",
                    total_issues,
                    unsigned.len(),
                    invalid_sig.len(),
                    no_examples.len(),
                    unknown_effects.len(),
                    deprecated_no_successor.len()
                )
            },
        }))
    );
}