Skip to main content

harn_cli/commands/
flow.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use harn_vm::flow::{IntentClusterer, ObservedAtom, SqliteFlowStore, TextOp, VcsBackend};
7use serde::ser::SerializeStruct;
8use serde::Serialize;
9use serde_json::json;
10use time::format_description::well_known::Rfc3339;
11use time::{Date, Duration, OffsetDateTime, Time};
12
13use crate::cli::{
14    FlowArchivistCommand, FlowArgs, FlowCommand, FlowReplayAuditArgs, FlowShipCommand,
15};
16
17const SHIP_CAPTAIN_EVAL_PACKS: [&str; 4] = [
18    "slice_quality",
19    "false_ship_rate",
20    "coverage_fidelity",
21    "latency_pr_to_merge",
22];
23
24pub(crate) fn run_flow(args: &FlowArgs) -> Result<i32, String> {
25    match &args.command {
26        FlowCommand::ReplayAudit(replay) => run_replay_audit(replay),
27        FlowCommand::Ship(ship) => match &ship.command {
28            FlowShipCommand::Watch(watch) => run_ship_watch(watch),
29        },
30        FlowCommand::Archivist(archivist) => match &archivist.command {
31            FlowArchivistCommand::Scan(scan) => run_archivist_scan(scan),
32        },
33    }
34}
35
36pub(crate) fn run_replay_audit(args: &FlowReplayAuditArgs) -> Result<i32, String> {
37    let since = args.since.as_deref().map(parse_since).transpose()?;
38    if !args.store.is_file() {
39        return Err(format!(
40            "Flow store {} does not exist",
41            args.store.display()
42        ));
43    }
44    let store = SqliteFlowStore::open(&args.store, "replay-audit").map_err(|error| {
45        format!(
46            "failed to open Flow store {}: {error}",
47            args.store.display()
48        )
49    })?;
50
51    let chains = current_predicate_chains(&args.predicate_root, &args.touched_dirs);
52    let diagnostics = discovery_diagnostics(&chains);
53    if has_discovery_error(&diagnostics) {
54        return Err(render_discovery_diagnostics(&diagnostics));
55    }
56    if !args.json {
57        print_discovery_warnings(&diagnostics);
58    }
59
60    let current_predicates = harn_vm::flow::resolve_predicates_for_touched_directories(&chains);
61    let stored = store
62        .shipped_derived_slices_since(since)
63        .map_err(|error| format!("failed to list shipped slices: {error}"))?;
64    let created_at_by_slice = stored
65        .iter()
66        .map(|stored| (stored.slice.id, stored.created_at.clone()))
67        .collect::<std::collections::BTreeMap<_, _>>();
68    let report = harn_vm::flow::replay_audit_report(
69        stored.into_iter().map(|stored| stored.slice),
70        &current_predicates,
71    );
72
73    if args.json {
74        let json = serde_json::to_string_pretty(&report)
75            .map_err(|error| format!("failed to encode replay-audit report: {error}"))?;
76        println!("{json}");
77    } else {
78        print_human_report(
79            args.since.as_deref().unwrap_or("beginning"),
80            &report,
81            &created_at_by_slice,
82        );
83    }
84
85    Ok(i32::from(args.fail_on_drift && report.has_drift()))
86}
87
88/// Inputs for [`ship_watch_payload`]. Mirrors the fields the CLI's
89/// `harn flow ship watch` accepts, but lets in-process callers (tests
90/// and library consumers) build the JSON receipt without going through
91/// clap or the binary surface.
92#[derive(Debug, Clone)]
93pub struct FlowShipWatchInputs<'a> {
94    pub store: &'a Path,
95    pub predicate_root: &'a Path,
96    pub touched_dirs: &'a [PathBuf],
97    pub persona: &'a str,
98    /// Optional path to receive the JSON receipt as a side effect, matching
99    /// the CLI's `--mock-pr-out` flag.
100    pub mock_pr_out: Option<&'a Path>,
101}
102
103/// In-process implementation of `harn flow ship watch`.
104///
105/// Returns the same JSON payload the CLI prints to stdout. When
106/// `inputs.mock_pr_out` is set the payload is also written to that
107/// path, matching the binary's `--mock-pr-out` behavior so callers can
108/// verify the file/stdout contract without spawning a subprocess.
109pub fn ship_watch_payload(inputs: &FlowShipWatchInputs<'_>) -> Result<serde_json::Value, String> {
110    let store = open_store(inputs.store)?;
111    let atom_refs = store
112        .list_atoms()
113        .map_err(|error| format!("failed to list Flow atoms: {error}"))?;
114    if atom_refs.is_empty() {
115        // Idle payload intentionally does not honor `mock_pr_out` — the
116        // CLI's pre-#1106 behavior only wrote the receipt file when an
117        // actual mock PR was opened, not on idle no-op runs.
118        return Ok(json!({
119            "status": "idle",
120            "reason": "no_atoms",
121            "persona": inputs.persona,
122            "phase": "phase_0",
123            "mode": "shadow",
124            "autonomy": "propose_with_approval",
125            "receipts_required": true,
126        }));
127    }
128
129    let atoms = atom_refs
130        .iter()
131        .map(|atom_ref| {
132            store
133                .get_atom(atom_ref.atom_id)
134                .map_err(|error| format!("failed to load atom {}: {error}", atom_ref.atom_id))
135        })
136        .collect::<Result<Vec<_>, _>>()?;
137    let intents = IntentClusterer::default().cluster(
138        atoms
139            .iter()
140            .enumerate()
141            .map(|(index, atom)| ObservedAtom::from_atom(atom, (index + 1) as u64)),
142    );
143    let intent_payload = intents
144        .iter()
145        .map(|intent| {
146            json!({
147                "id": intent.id,
148                "goal_description": intent.goal_description,
149                "atoms": intent.atoms,
150                "confidence": intent.confidence,
151                "origin_transcript_span": intent.origin_transcript_span,
152            })
153        })
154        .collect::<Vec<_>>();
155
156    let chains = current_predicate_chains(inputs.predicate_root, inputs.touched_dirs);
157    let diagnostics = discovery_diagnostics(&chains);
158    if has_discovery_error(&diagnostics) {
159        return Err(render_discovery_diagnostics(&diagnostics));
160    }
161    let bootstrap_payload = bootstrap_policy_payload(inputs.predicate_root);
162    let predicates = harn_vm::flow::resolve_predicates_for_touched_directories(&chains);
163    let predicate_payload = predicates
164        .iter()
165        .map(|predicate| {
166            json!({
167                "qualified_name": predicate.qualified_name,
168                "logical_name": predicate.logical_name,
169                "hash": predicate.predicate.source_hash,
170                "kind": predicate.predicate.kind,
171                "relative_dir": predicate.source.relative_dir,
172                "retroactive": predicate.predicate.retroactive,
173            })
174        })
175        .collect::<Vec<_>>();
176    let ceiling = harn_vm::flow::PredicateCeiling::default();
177    let ceiling_outcome = harn_vm::flow::enforce_predicate_ceiling(&predicates, &ceiling);
178    let ceiling_payload = serialize_ceiling_outcome(&ceiling_outcome, &ceiling);
179    let validation_status = match ceiling_outcome.violation().map(|v| v.level) {
180        None => "ok",
181        Some(harn_vm::flow::PredicateCeilingLevel::RequireApproval) => "require_approval",
182        Some(harn_vm::flow::PredicateCeilingLevel::Block) => "blocked",
183    };
184
185    let atom_ids: Vec<_> = atom_refs.iter().map(|atom| atom.atom_id).collect();
186    let slice = store
187        .derive_slice(&atom_ids)
188        .map_err(|error| format!("failed to derive candidate slice: {error}"))?;
189    let ship_receipt = store
190        .ship_slice(&slice)
191        .map_err(|error| format!("failed to persist Ship Captain receipt: {error}"))?;
192    let created_at = OffsetDateTime::now_utc()
193        .format(&Rfc3339)
194        .map_err(|error| format!("failed to format receipt timestamp: {error}"))?;
195    let mock_pr = json!({
196        "number": 0,
197        "state": "open",
198        "url": format!("mock://github/pull/{}", slice.id),
199        "title": format!("Flow slice {}", slice.id),
200        "body": format!(
201            "Shadow-mode Ship Captain candidate slice.\n\nAtoms: {}\nIntents: {}\nPredicates discovered: {}\nValidation: {}\n\nNo remote PR was opened.",
202            slice.atoms.len(),
203            intents.len(),
204            predicates.len(),
205            validation_status,
206        ),
207        "requires_approval": true,
208        "validation_status": validation_status,
209    });
210    let payload = json!({
211        "status": "mock_pr_opened",
212        "persona": inputs.persona,
213        "phase": "phase_0",
214        "mode": "shadow",
215        "autonomy": "propose_with_approval",
216        "receipts_required": true,
217        "created_at": created_at,
218        "slice": {
219            "id": slice.id,
220            "atoms": slice.atoms,
221            "atom_count": slice.atoms.len(),
222        },
223        "intents": intent_payload,
224        "predicate_validation": {
225            "predicate_root": inputs.predicate_root,
226            "touched_dirs": if inputs.touched_dirs.is_empty() {
227                vec![PathBuf::from(".")]
228            } else {
229                inputs.touched_dirs.to_vec()
230            },
231            "status": validation_status,
232            "predicates": predicate_payload,
233            "ceiling": ceiling_payload,
234            "bootstrap_policy": bootstrap_payload,
235            "diagnostics": diagnostics.iter().map(|(path, diagnostic)| json!({
236                "path": path,
237                "severity": discovery_severity_label(diagnostic.severity),
238                "message": diagnostic.message,
239            })).collect::<Vec<_>>(),
240        },
241        "ship_receipt": {
242            "slice_id": ship_receipt.slice_id,
243            "commit": ship_receipt.commit,
244            "ref_name": ship_receipt.ref_name,
245        },
246        "mock_pr": mock_pr,
247        "eval_packs": SHIP_CAPTAIN_EVAL_PACKS,
248    });
249
250    if let Some(path) = inputs.mock_pr_out {
251        write_json(path, &payload)
252            .map_err(|error| format!("failed to write mock PR receipt: {error}"))?;
253    }
254    Ok(payload)
255}
256
257fn run_ship_watch(args: &crate::cli::FlowShipWatchArgs) -> Result<i32, String> {
258    let inputs = FlowShipWatchInputs {
259        store: &args.store,
260        predicate_root: &args.predicate_root,
261        touched_dirs: &args.touched_dirs,
262        persona: &args.persona,
263        mock_pr_out: args.mock_pr_out.as_deref(),
264    };
265
266    if !args.json {
267        let chains = current_predicate_chains(&args.predicate_root, &args.touched_dirs);
268        let diagnostics = discovery_diagnostics(&chains);
269        if !has_discovery_error(&diagnostics) {
270            print_discovery_warnings(&diagnostics);
271        }
272    }
273
274    let payload = ship_watch_payload(&inputs)?;
275    let summary = match payload.get("status").and_then(|status| status.as_str()) {
276        Some("idle") => "Ship Captain idle: no atoms in the Flow store.".to_string(),
277        _ => match payload
278            .get("slice")
279            .and_then(|slice| slice.get("id"))
280            .and_then(|id| id.as_str())
281        {
282            Some(slice_id) => format!("mock PR opened for candidate slice {slice_id}"),
283            None => "Ship Captain receipt emitted.".to_string(),
284        },
285    };
286    print_payload(args.json, &summary, &payload);
287    Ok(0)
288}
289
290fn serialize_ceiling_outcome(
291    outcome: &harn_vm::flow::PredicateCeilingOutcome,
292    ceiling: &harn_vm::flow::PredicateCeiling,
293) -> serde_json::Value {
294    use harn_vm::flow::{PredicateCeilingLevel, PredicateCeilingOutcome};
295    let mut payload = json!({
296        "count": outcome.count(),
297        "require_approval_threshold": ceiling.require_approval_threshold,
298        "block_threshold": ceiling.block_threshold,
299    });
300    match outcome {
301        PredicateCeilingOutcome::Within { .. } => {
302            payload["status"] = json!("within");
303        }
304        PredicateCeilingOutcome::Exceeded(violation) => {
305            payload["status"] = json!(match violation.level {
306                PredicateCeilingLevel::RequireApproval => "require_approval",
307                PredicateCeilingLevel::Block => "blocked",
308            });
309            payload["threshold"] = json!(violation.threshold);
310            payload["message"] = json!(violation.message());
311            payload["top_contributors"] = json!(violation
312                .top_contributors
313                .iter()
314                .map(|item| json!({
315                    "relative_dir": item.relative_dir,
316                    "count": item.count,
317                }))
318                .collect::<Vec<_>>());
319        }
320    }
321    payload
322}
323
324fn bootstrap_policy_payload(predicate_root: &Path) -> serde_json::Value {
325    use harn_vm::flow::Approver;
326    let Some(discovered) = harn_vm::flow::discover_bootstrap_policy(predicate_root) else {
327        return json!({
328            "status": "absent",
329            "path": predicate_root.join(harn_vm::flow::META_INVARIANTS_FILE),
330        });
331    };
332    let maintainers = discovered
333        .policy
334        .maintainers
335        .iter()
336        .map(|approver| match approver {
337            Approver::Role { name } => json!({"kind": "role", "id": name}),
338            Approver::Principal { id } => json!({"kind": "principal", "id": id}),
339        })
340        .collect::<Vec<_>>();
341    let diagnostics = discovered
342        .diagnostics
343        .iter()
344        .map(|diagnostic| {
345            json!({
346                "severity": discovery_severity_label(diagnostic.severity),
347                "message": diagnostic.message,
348            })
349        })
350        .collect::<Vec<_>>();
351    json!({
352        "status": "present",
353        "path": discovered.path,
354        "hash": discovered.policy.hash,
355        "maintainers": maintainers,
356        "diagnostics": diagnostics,
357    })
358}
359
360fn discovery_severity_label(severity: harn_vm::flow::DiscoveryDiagnosticSeverity) -> &'static str {
361    match severity {
362        harn_vm::flow::DiscoveryDiagnosticSeverity::Warning => "warning",
363        harn_vm::flow::DiscoveryDiagnosticSeverity::Error => "error",
364    }
365}
366
367fn run_archivist_scan(args: &crate::cli::FlowArchivistScanArgs) -> Result<i32, String> {
368    let repo = args
369        .repo
370        .canonicalize()
371        .unwrap_or_else(|_| args.repo.clone());
372    let source_date = OffsetDateTime::now_utc().date().to_string();
373    let inventory = inventory_repo(&repo);
374    let stack_hints = inventory.stack_hints.clone();
375    let manifest = load_archivist_manifest(&repo, args.manifest.as_deref());
376    let invariant_files = find_invariant_dirs(&repo);
377    let mut seen = BTreeSet::new();
378    let mut predicates = Vec::new();
379    let mut discovery_diagnostics = Vec::new();
380    for dir in &invariant_files {
381        for file in harn_vm::flow::discover_invariants(&repo, dir) {
382            let relative_dir = file.relative_dir.clone();
383            for diagnostic in &file.diagnostics {
384                discovery_diagnostics.push(json!({
385                    "relative_dir": relative_dir,
386                    "path": file.path,
387                    "severity": format!("{:?}", diagnostic.severity).to_lowercase(),
388                    "message": diagnostic.message,
389                }));
390            }
391            for predicate in file.predicates {
392                if !seen.insert(predicate.source_hash.clone()) {
393                    continue;
394                }
395                predicates.push(json!({
396                    "name": predicate.name,
397                    "hash": predicate.source_hash,
398                    "kind": predicate.kind,
399                    "fallback": predicate.fallback,
400                    "relative_dir": relative_dir.clone(),
401                    "retroactive": predicate.retroactive,
402                    "archivist": predicate.archivist.map(|archivist| json!({
403                        "evidence": archivist.evidence,
404                        "confidence": archivist.confidence,
405                        "source_date": archivist.source_date,
406                        "coverage_examples": archivist.coverage_examples,
407                    })),
408                }));
409            }
410        }
411    }
412    let convention = mine_convention_signals(&repo);
413    let motion = mine_motion_signals(&repo);
414    let bootstrap_payload = bootstrap_policy_payload(&repo);
415    let proposals = archivist_proposals(
416        &repo,
417        &inventory,
418        &convention,
419        &motion,
420        predicates.is_empty(),
421        &source_date,
422    );
423    let shadow_evaluation = shadow_evaluate(&repo, &args.store, args.shadow_days, &proposals)?;
424    let payload = json!({
425        "status": "proposal_set",
426        "persona": {
427            "name": "archivist",
428            "mode": "propose_only",
429            "autonomy": "propose_only",
430            "promotion": "human_review_required",
431        },
432        "repo": repo,
433        "manifest": manifest,
434        "inventory": inventory,
435        "stack_hints": stack_hints,
436        "convention_signals": convention,
437        "motion_signals": motion,
438        "seed_library": {
439            "repository": "https://github.com/burin-labs/harn-canon",
440            "strategy": "detected-stack seeds are copied into proposals, then repo-local evidence prunes them before review",
441        },
442        "existing_predicates": predicates,
443        "discovery_diagnostics": discovery_diagnostics,
444        "bootstrap_policy": bootstrap_payload,
445        "proposals": proposals,
446        "shadow_evaluation": shadow_evaluation,
447    });
448
449    if let Some(path) = &args.out {
450        write_json(path, &payload)
451            .map_err(|error| format!("failed to write Archivist proposal set: {error}"))?;
452    }
453    print_payload(args.json, "Archivist proposal set emitted.", &payload);
454    Ok(0)
455}
456
457#[derive(Clone, Debug, Default, Serialize)]
458struct RepoInventory {
459    stack_hints: Vec<&'static str>,
460    lockfiles: Vec<String>,
461    config_files: Vec<String>,
462    source_roots: Vec<String>,
463}
464
465#[derive(Clone, Debug, Serialize)]
466struct Signal {
467    kind: &'static str,
468    path: String,
469    detail: String,
470}
471
472#[derive(Clone, Debug, Serialize)]
473struct MotionSignal {
474    kind: &'static str,
475    count: usize,
476    examples: Vec<String>,
477}
478
479#[derive(Clone, Debug)]
480struct ArchivistProposal {
481    id: &'static str,
482    title: &'static str,
483    path: String,
484    rationale: String,
485    predicate_name: &'static str,
486    match_terms: Vec<&'static str>,
487    evidence: Vec<String>,
488    confidence: f64,
489    coverage_examples: Vec<String>,
490    source: String,
491}
492
493impl Serialize for ArchivistProposal {
494    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
495        let mut state = serializer.serialize_struct("ArchivistProposal", 11)?;
496        state.serialize_field("id", self.id)?;
497        state.serialize_field("title", self.title)?;
498        state.serialize_field("path", &self.path)?;
499        state.serialize_field("rationale", &self.rationale)?;
500        state.serialize_field("predicate_name", self.predicate_name)?;
501        state.serialize_field("autonomy", "propose_only")?;
502        state.serialize_field("promotion", "human_review_required")?;
503        state.serialize_field("evidence", &self.evidence)?;
504        state.serialize_field("confidence", &self.confidence)?;
505        state.serialize_field("coverage_examples", &self.coverage_examples)?;
506        state.serialize_field("predicate_source", &self.source)?;
507        state.end()
508    }
509}
510
511fn inventory_repo(repo: &Path) -> RepoInventory {
512    let mut inventory = RepoInventory::default();
513    let known = [
514        ("Cargo.toml", "rust", "config"),
515        ("Cargo.lock", "rust", "lockfile"),
516        ("rust-toolchain.toml", "rust", "config"),
517        ("rustfmt.toml", "rust", "config"),
518        ("clippy.toml", "rust", "config"),
519        ("package.json", "javascript", "config"),
520        ("package-lock.json", "javascript", "lockfile"),
521        ("pnpm-lock.yaml", "javascript", "lockfile"),
522        ("yarn.lock", "javascript", "lockfile"),
523        ("tsconfig.json", "typescript", "config"),
524        ("pyproject.toml", "python", "config"),
525        ("poetry.lock", "python", "lockfile"),
526        ("uv.lock", "python", "lockfile"),
527        ("go.mod", "go", "config"),
528        ("go.sum", "go", "lockfile"),
529        ("Package.swift", "swift", "config"),
530    ];
531    for (path, stack, kind) in known {
532        if repo.join(path).exists() {
533            push_unique(&mut inventory.stack_hints, stack);
534            match kind {
535                "lockfile" => inventory.lockfiles.push(path.to_string()),
536                _ => inventory.config_files.push(path.to_string()),
537            }
538        }
539    }
540    if repo.join(".github/workflows").is_dir() {
541        inventory.config_files.push(".github/workflows".to_string());
542    }
543    for root in ["crates", "src", "docs/src", "conformance/tests", "examples"] {
544        if repo.join(root).exists() {
545            inventory.source_roots.push(root.to_string());
546        }
547    }
548    inventory
549}
550
551fn push_unique(values: &mut Vec<&'static str>, value: &'static str) {
552    if !values.contains(&value) {
553        values.push(value);
554    }
555}
556
557fn load_archivist_manifest(repo: &Path, explicit: Option<&Path>) -> serde_json::Value {
558    let explicit_manifest = explicit.is_some();
559    let candidates = explicit
560        .map(|path| vec![path.to_path_buf()])
561        .unwrap_or_else(|| {
562            [
563                repo.join("harn.toml"),
564                repo.join("examples/personas/flow.harn.toml"),
565                repo.join("examples/personas/harn.toml"),
566            ]
567            .into_iter()
568            .filter(|path| path.is_file())
569            .collect()
570        });
571    let mut loaded_without_archivist = None;
572    let mut first_invalid = None;
573    for candidate in candidates {
574        match crate::package::load_personas_from_manifest_path(&candidate) {
575            Ok(catalog) => {
576                let archivist = catalog
577                    .personas
578                    .iter()
579                    .find(|persona| persona.name.as_deref() == Some("archivist"));
580                if let Some(persona) = archivist {
581                    return json!({
582                        "status": "loaded",
583                        "path": catalog.manifest_path,
584                        "persona": persona,
585                    });
586                }
587                loaded_without_archivist.get_or_insert_with(|| json!({
588                    "status": "loaded_without_archivist",
589                    "path": catalog.manifest_path,
590                    "personas": catalog.personas.iter().filter_map(|p| p.name.clone()).collect::<Vec<_>>(),
591                }));
592            }
593            Err(errors) => {
594                let invalid = json!({
595                    "status": "invalid",
596                    "path": candidate,
597                    "errors": errors.iter().map(ToString::to_string).collect::<Vec<_>>(),
598                });
599                if explicit_manifest {
600                    return invalid;
601                }
602                first_invalid.get_or_insert(invalid);
603            }
604        }
605    }
606    if let Some(loaded) = loaded_without_archivist {
607        return loaded;
608    }
609    if let Some(invalid) = first_invalid {
610        return invalid;
611    }
612    json!({
613        "status": "not_found",
614        "searched": ["harn.toml", "examples/personas/flow.harn.toml", "examples/personas/harn.toml"],
615    })
616}
617
618fn mine_convention_signals(repo: &Path) -> Vec<Signal> {
619    let mut signals = Vec::new();
620    for path in walk_repo_files(repo, 4_000) {
621        let relative = relative_path(repo, &path);
622        let file_name = path
623            .file_name()
624            .and_then(|name| name.to_str())
625            .unwrap_or("");
626        if matches!(
627            file_name,
628            "rustfmt.toml" | "clippy.toml" | "deny.toml" | ".markdownlint.json" | ".prettierrc"
629        ) {
630            signals.push(Signal {
631                kind: "lint_config",
632                path: relative.clone(),
633                detail: "repo-local style or lint policy".to_string(),
634            });
635        }
636        if relative.ends_with(".harn")
637            || relative.ends_with(".rs")
638            || relative.ends_with(".md")
639            || relative.ends_with(".toml")
640        {
641            if let Ok(source) = fs::read_to_string(&path) {
642                for (index, line) in source.lines().enumerate() {
643                    let trimmed = line.trim_start();
644                    let is_comment = trimmed.starts_with("//")
645                        || trimmed.starts_with('#')
646                        || trimmed.starts_with("<!--");
647                    if is_comment {
648                        if let Some(pos) = trimmed.to_ascii_lowercase().find("invariant:") {
649                            signals.push(Signal {
650                                kind: "inline_invariant",
651                                path: format!("{relative}:{}", index + 1),
652                                detail: trimmed[pos..].trim().chars().take(180).collect(),
653                            });
654                        }
655                    }
656                    if signals.len() >= 80 {
657                        return signals;
658                    }
659                }
660            }
661        }
662    }
663    signals
664}
665
666fn mine_motion_signals(repo: &Path) -> Vec<MotionSignal> {
667    let output = Command::new("git")
668        .arg("-C")
669        .arg(repo)
670        .args([
671            "log",
672            "--since=90 days ago",
673            "--pretty=%s",
674            "--max-count=200",
675        ])
676        .output();
677    let Ok(output) = output else {
678        return Vec::new();
679    };
680    if !output.status.success() {
681        return Vec::new();
682    }
683    let stdout = String::from_utf8_lossy(&output.stdout);
684    let buckets: [(&str, &[&str]); 4] = [
685        ("tests", &["test", "coverage", "conformance"]),
686        ("lint_format", &["lint", "format", "fmt", "clippy"]),
687        (
688            "flow_predicates",
689            &["flow", "predicate", "invariant", "archivist"],
690        ),
691        ("release_docs", &["release", "docs", "changelog"]),
692    ];
693    let mut counts: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
694    for subject in stdout.lines() {
695        let lower = subject.to_ascii_lowercase();
696        for (kind, terms) in buckets {
697            if terms.iter().any(|term| lower.contains(term)) {
698                counts
699                    .entry(kind)
700                    .or_default()
701                    .push(subject.chars().take(140).collect());
702            }
703        }
704    }
705    counts
706        .into_iter()
707        .map(|(kind, examples)| MotionSignal {
708            kind,
709            count: examples.len(),
710            examples: examples.into_iter().take(5).collect(),
711        })
712        .collect()
713}
714
715fn archivist_proposals(
716    repo: &Path,
717    inventory: &RepoInventory,
718    convention: &[Signal],
719    motion: &[MotionSignal],
720    no_existing_predicates: bool,
721    source_date: &str,
722) -> Vec<ArchivistProposal> {
723    let mut proposals = Vec::new();
724    if no_existing_predicates {
725        proposals.push(bootstrap_proposal(source_date));
726    }
727    if inventory.stack_hints.contains(&"rust") {
728        proposals.push(rust_unsafe_proposal(repo, source_date));
729        proposals.push(rust_panics_proposal(repo, source_date));
730    }
731    if inventory
732        .config_files
733        .iter()
734        .any(|path| path == ".github/workflows")
735    {
736        proposals.push(github_actions_permissions_proposal(source_date));
737    }
738    if motion
739        .iter()
740        .any(|signal| signal.kind == "tests" && signal.count >= 3)
741    {
742        proposals.push(test_motion_proposal(source_date));
743    }
744    let inline_signals = convention
745        .iter()
746        .filter(|signal| signal.kind == "inline_invariant")
747        .take(5)
748        .collect::<Vec<_>>();
749    if !inline_signals.is_empty() {
750        proposals.push(inline_invariant_proposal(&inline_signals, source_date));
751    }
752    proposals
753}
754
755fn bootstrap_proposal(source_date: &str) -> ArchivistProposal {
756    let evidence = vec![
757        "https://slsa.dev/spec/v1.0/provenance".to_string(),
758        "https://in-toto.io/attestation-spec/".to_string(),
759    ];
760    let coverage_examples = vec![
761        "invariants.harn".to_string(),
762        "meta-invariants.harn".to_string(),
763    ];
764    proposal(
765        "bootstrap-meta-invariants",
766        "Seed repo-wide predicate authorship metadata",
767        "invariants.harn",
768        "The repository has no discovered Flow predicates; seed review-only bootstrap metadata before expanding policy.",
769        "predicate_metadata_is_reviewable",
770        vec!["@archivist", "@semantic", "@deterministic"],
771        evidence,
772        0.72,
773        coverage_examples,
774        source_date,
775        "flow_invariant_warn(\"bootstrap predicate metadata should be reviewed by a human maintainer\")",
776    )
777}
778
779fn rust_unsafe_proposal(repo: &Path, source_date: &str) -> ArchivistProposal {
780    let examples = files_containing(repo, "unsafe", &["rs"], 5);
781    proposal(
782        "rust-unsafe-safety-comment",
783        "Require review evidence near new Rust unsafe blocks",
784        "invariants.harn",
785        "Rust is detected and unsafe blocks are a recurring high-value review boundary; propose a deterministic guard that warns on unsafe additions without nearby safety rationale.",
786        "rust_unsafe_requires_safety_comment",
787        vec!["unsafe", "SAFETY:"],
788        vec![
789            "https://doc.rust-lang.org/clippy/lint_configuration.html#undocumented_unsafe_blocks".to_string(),
790            "https://rust-lang.github.io/api-guidelines/documentation.html".to_string(),
791        ],
792        0.82,
793        examples,
794        source_date,
795        "flow_invariant_warn(\"new unsafe code should include nearby SAFETY rationale or explicit reviewer approval\")",
796    )
797}
798
799fn rust_panics_proposal(repo: &Path, source_date: &str) -> ArchivistProposal {
800    let mut examples = files_containing(repo, "panic!", &["rs"], 5);
801    examples.extend(files_containing(
802        repo,
803        ".unwrap()",
804        &["rs"],
805        5 - examples.len().min(5),
806    ));
807    proposal(
808        "rust-library-panic-surface",
809        "Flag new library panic surfaces without tests or documentation",
810        "invariants.harn",
811        "The Rust API Guidelines call out documented panic conditions; Flow can cheaply warn when atoms add panic-prone surfaces in library crates.",
812        "rust_library_panics_are_documented",
813        vec!["panic!", "unwrap()", "expect("],
814        vec![
815            "https://rust-lang.github.io/api-guidelines/documentation.html#c-failure".to_string(),
816            "https://rust-lang.github.io/rust-clippy/beta/".to_string(),
817        ],
818        0.76,
819        examples,
820        source_date,
821        "flow_invariant_warn(\"new panic-prone Rust paths should include tests or documented panic conditions\")",
822    )
823}
824
825fn github_actions_permissions_proposal(source_date: &str) -> ArchivistProposal {
826    proposal(
827        "github-actions-minimal-permissions",
828        "Warn on workflow edits without explicit permissions",
829        ".github/invariants.harn",
830        "GitHub workflow files are present; explicit job/workflow permissions make CI authority reviewable and reduce supply-chain blast radius.",
831        "github_actions_permissions_are_explicit",
832        vec!["permissions:", "uses:"],
833        vec![
834            "https://docs.github.com/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions".to_string(),
835            "https://docs.github.com/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security".to_string(),
836        ],
837        0.79,
838        vec![".github/workflows".to_string()],
839        source_date,
840        "flow_invariant_warn(\"workflow edits should keep explicit least-privilege permissions\")",
841    )
842}
843
844fn test_motion_proposal(source_date: &str) -> ArchivistProposal {
845    proposal(
846        "motion-tests-near-flow-changes",
847        "Keep test coverage close to recurring Flow changes",
848        "invariants.harn",
849        "Recent history repeatedly touches tests/conformance around Flow work; propose a warning when Flow atoms lack nearby test coverage evidence.",
850        "flow_changes_keep_tests_nearby",
851        vec!["flow", "predicate", "conformance", "test"],
852        vec![
853            "git log --since='90 days ago' --pretty=%s".to_string(),
854            "conformance/tests/".to_string(),
855        ],
856        0.68,
857        vec!["crates/harn-vm/src/flow".to_string(), "conformance/tests".to_string()],
858        source_date,
859        "flow_invariant_warn(\"Flow predicate/runtime changes should carry focused tests or conformance coverage\")",
860    )
861}
862
863fn inline_invariant_proposal(signals: &[&Signal], source_date: &str) -> ArchivistProposal {
864    let id = "inline-invariant-crystallization";
865    let examples = signals
866        .iter()
867        .map(|signal| signal.path.clone())
868        .collect::<Vec<_>>();
869    proposal(
870        id,
871        "Crystallize inline invariant comment into Flow predicate",
872        "invariants.harn",
873        "Found inline invariant comments; propose turning recurring comments into reviewable predicate metadata.",
874        "inline_invariant_comment_is_crystallized",
875        vec!["invariant:"],
876        examples.clone(),
877        0.64,
878        examples,
879        source_date,
880        "flow_invariant_warn(\"inline invariant comments should graduate into reviewable Flow predicates when they recur\")",
881    )
882}
883
884#[allow(clippy::too_many_arguments)]
885fn proposal(
886    id: &'static str,
887    title: &'static str,
888    path: &str,
889    rationale: &str,
890    predicate_name: &'static str,
891    match_terms: Vec<&'static str>,
892    evidence: Vec<String>,
893    confidence: f64,
894    coverage_examples: Vec<String>,
895    source_date: &str,
896    result_expr: &str,
897) -> ArchivistProposal {
898    let evidence_harn = evidence
899        .iter()
900        .map(|item| format!("{item:?}"))
901        .collect::<Vec<_>>()
902        .join(", ");
903    let coverage_harn = coverage_examples
904        .iter()
905        .map(|item| format!("{item:?}"))
906        .collect::<Vec<_>>()
907        .join(", ");
908    let source = format!(
909        "@invariant\n@deterministic\n@archivist(evidence: [{evidence_harn}], confidence: {confidence:.2}, source_date: {source_date:?}, coverage_examples: [{coverage_harn}])\nfn {predicate_name}(slice) {{\n  return {result_expr}\n}}\n"
910    );
911    ArchivistProposal {
912        id,
913        title,
914        path: path.to_string(),
915        rationale: rationale.to_string(),
916        predicate_name,
917        match_terms,
918        evidence,
919        confidence,
920        coverage_examples,
921        source,
922    }
923}
924
925fn shadow_evaluate(
926    repo: &Path,
927    store_path: &Path,
928    shadow_days: u32,
929    proposals: &[ArchivistProposal],
930) -> Result<serde_json::Value, String> {
931    let store_path = if store_path.is_absolute() {
932        store_path.to_path_buf()
933    } else {
934        repo.join(store_path)
935    };
936    if !store_path.is_file() {
937        return Ok(json!({
938            "status": "no_flow_store",
939            "store": store_path,
940            "window_days": shadow_days,
941            "recent_atoms": 0,
942            "proposal_results": empty_shadow_results(proposals),
943            "false_positive_candidates": [],
944        }));
945    }
946    let store = SqliteFlowStore::open(&store_path, "archivist-shadow").map_err(|error| {
947        format!(
948            "failed to open Flow store {}: {error}",
949            store_path.display()
950        )
951    })?;
952    let since = OffsetDateTime::now_utc() - Duration::days(i64::from(shadow_days));
953    let refs = store
954        .list_atoms()
955        .map_err(|error| format!("failed to list Flow atoms: {error}"))?;
956    let mut recent_atoms = Vec::new();
957    for atom_ref in refs {
958        let atom = store
959            .get_atom(atom_ref.atom_id)
960            .map_err(|error| format!("failed to load Flow atom {}: {error}", atom_ref.atom_id))?;
961        if atom.provenance.timestamp >= since {
962            recent_atoms.push(atom);
963        }
964    }
965
966    let mut false_positive_candidates = Vec::new();
967    let mut results = Vec::new();
968    for proposal in proposals {
969        let mut matched_atoms = 0usize;
970        for atom in &recent_atoms {
971            let inserted = inserted_text(atom);
972            if proposal.match_terms.iter().any(|term| {
973                inserted
974                    .to_ascii_lowercase()
975                    .contains(&term.to_ascii_lowercase())
976            }) {
977                matched_atoms += 1;
978                if likely_false_positive(proposal, &inserted) {
979                    false_positive_candidates.push(json!({
980                        "proposal_id": proposal.id,
981                        "atom": atom.id,
982                        "transcript_ref": atom.provenance.transcript_ref,
983                        "diff_span": first_insert_span(atom),
984                        "reason": "heuristic match may already contain satisfying context",
985                    }));
986                }
987            }
988        }
989        results.push(json!({
990            "proposal_id": proposal.id,
991            "recent_atoms": recent_atoms.len(),
992            "matching_atoms": matched_atoms,
993            "estimated_coverage": if recent_atoms.is_empty() { 0.0 } else { matched_atoms as f64 / recent_atoms.len() as f64 },
994        }));
995    }
996    Ok(json!({
997        "status": "evaluated",
998        "store": store_path,
999        "window_days": shadow_days,
1000        "recent_atoms": recent_atoms.len(),
1001        "proposal_results": results,
1002        "false_positive_candidates": false_positive_candidates,
1003    }))
1004}
1005
1006fn empty_shadow_results(proposals: &[ArchivistProposal]) -> Vec<serde_json::Value> {
1007    proposals
1008        .iter()
1009        .map(|proposal| {
1010            json!({
1011                "proposal_id": proposal.id,
1012                "recent_atoms": 0,
1013                "matching_atoms": 0,
1014                "estimated_coverage": 0.0,
1015            })
1016        })
1017        .collect()
1018}
1019
1020fn inserted_text(atom: &harn_vm::flow::Atom) -> String {
1021    atom.ops
1022        .iter()
1023        .filter_map(|op| match op {
1024            TextOp::Insert { content, .. } => Some(content.as_str()),
1025            TextOp::Delete { .. } => None,
1026        })
1027        .collect::<Vec<_>>()
1028        .join("\n")
1029}
1030
1031fn first_insert_span(atom: &harn_vm::flow::Atom) -> serde_json::Value {
1032    atom.ops
1033        .iter()
1034        .find_map(|op| match op {
1035            TextOp::Insert { offset, content } => Some(json!({
1036                "start": offset,
1037                "end": offset.saturating_add(content.len() as u64),
1038            })),
1039            TextOp::Delete { .. } => None,
1040        })
1041        .unwrap_or_else(|| json!({"start": 0, "end": 0}))
1042}
1043
1044fn likely_false_positive(proposal: &ArchivistProposal, inserted: &str) -> bool {
1045    match proposal.id {
1046        "rust-unsafe-safety-comment" => {
1047            inserted.contains("unsafe") && inserted.to_ascii_lowercase().contains("safety")
1048        }
1049        "github-actions-minimal-permissions" => {
1050            inserted.contains("permissions:") && inserted.contains("uses:")
1051        }
1052        _ => false,
1053    }
1054}
1055
1056fn files_containing(repo: &Path, needle: &str, extensions: &[&str], limit: usize) -> Vec<String> {
1057    if limit == 0 {
1058        return Vec::new();
1059    }
1060    let needle = needle.to_ascii_lowercase();
1061    let mut matches = Vec::new();
1062    for path in walk_repo_files(repo, 4_000) {
1063        let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
1064            continue;
1065        };
1066        if !extensions.contains(&ext) {
1067            continue;
1068        }
1069        let Ok(source) = fs::read_to_string(&path) else {
1070            continue;
1071        };
1072        if source.to_ascii_lowercase().contains(&needle) {
1073            matches.push(relative_path(repo, &path));
1074            if matches.len() >= limit {
1075                break;
1076            }
1077        }
1078    }
1079    matches
1080}
1081
1082fn walk_repo_files(repo: &Path, limit: usize) -> Vec<PathBuf> {
1083    let mut files = Vec::new();
1084    collect_repo_files(repo, repo, limit, &mut files);
1085    files
1086}
1087
1088fn collect_repo_files(root: &Path, dir: &Path, limit: usize, out: &mut Vec<PathBuf>) {
1089    if out.len() >= limit {
1090        return;
1091    }
1092    let Ok(entries) = fs::read_dir(dir) else {
1093        return;
1094    };
1095    let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
1096    entries.sort_by_key(|entry| entry.path());
1097    for entry in entries {
1098        if out.len() >= limit {
1099            return;
1100        }
1101        let path = entry.path();
1102        let name = path
1103            .file_name()
1104            .and_then(|name| name.to_str())
1105            .unwrap_or_default();
1106        if path.is_dir() {
1107            if should_skip_scan_dir(name) {
1108                continue;
1109            }
1110            collect_repo_files(root, &path, limit, out);
1111        } else if path.is_file() {
1112            let relative = relative_path(root, &path);
1113            if !relative.ends_with(".lock")
1114                || matches!(name, "Cargo.lock" | "package-lock.json" | "yarn.lock")
1115            {
1116                out.push(path);
1117            }
1118        }
1119    }
1120}
1121
1122fn should_skip_scan_dir(name: &str) -> bool {
1123    matches!(
1124        name,
1125        ".git"
1126            | "target"
1127            | "node_modules"
1128            | "docs/dist"
1129            | ".harn"
1130            | ".harn-runs"
1131            | ".claude"
1132            | ".burin"
1133    )
1134}
1135
1136fn relative_path(root: &Path, path: &Path) -> String {
1137    path.strip_prefix(root)
1138        .unwrap_or(path)
1139        .components()
1140        .filter_map(|component| match component {
1141            std::path::Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
1142            _ => None,
1143        })
1144        .collect::<Vec<_>>()
1145        .join("/")
1146}
1147
1148fn current_predicate_chains(
1149    root: &Path,
1150    touched_dirs: &[PathBuf],
1151) -> Vec<Vec<harn_vm::flow::DiscoveredInvariantFile>> {
1152    let dirs: Vec<PathBuf> = if touched_dirs.is_empty() {
1153        vec![PathBuf::from(".")]
1154    } else {
1155        touched_dirs.to_vec()
1156    };
1157    dirs.into_iter()
1158        .map(|dir| harn_vm::flow::discover_invariants(root, &dir))
1159        .collect()
1160}
1161
1162fn open_store(path: &Path) -> Result<SqliteFlowStore, String> {
1163    if let Some(parent) = path
1164        .parent()
1165        .filter(|parent| !parent.as_os_str().is_empty())
1166    {
1167        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1168    }
1169    SqliteFlowStore::open(path, "flow-cli").map_err(|error| error.to_string())
1170}
1171
1172fn find_invariant_dirs(root: &Path) -> Vec<PathBuf> {
1173    let mut dirs = Vec::new();
1174    collect_invariant_dirs(root, root, &mut dirs);
1175    dirs
1176}
1177
1178fn collect_invariant_dirs(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
1179    let Ok(entries) = fs::read_dir(dir) else {
1180        return;
1181    };
1182    let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
1183    entries.sort_by_key(|entry| entry.path());
1184    for entry in entries {
1185        let path = entry.path();
1186        if path.is_dir() {
1187            let name = path
1188                .file_name()
1189                .and_then(|name| name.to_str())
1190                .unwrap_or_default();
1191            if matches!(name, ".git" | "target" | "node_modules") {
1192                continue;
1193            }
1194            collect_invariant_dirs(root, &path, out);
1195        } else if path.file_name().and_then(|name| name.to_str()) == Some("invariants.harn") {
1196            out.push(path.parent().unwrap_or(root).to_path_buf());
1197        }
1198    }
1199}
1200
1201fn print_human_report(
1202    since: &str,
1203    report: &harn_vm::flow::ReplayAuditReport,
1204    created_at_by_slice: &std::collections::BTreeMap<harn_vm::flow::SliceId, String>,
1205) {
1206    println!(
1207        "Audited {} shipped derived slice(s) since {since}; {} slice(s) have advisory drift.",
1208        report.audited_slices, report.drifted_slices
1209    );
1210    if report.slices.is_empty() {
1211        return;
1212    }
1213    for slice in &report.slices {
1214        let created_at = created_at_by_slice
1215            .get(&slice.slice_id)
1216            .map(String::as_str)
1217            .unwrap_or("unknown");
1218        println!("slice {} created_at={created_at}", slice.slice_id);
1219        if !slice.advisory_drift.is_empty() {
1220            println!("  current @retroactive predicates not pinned:");
1221            for predicate in &slice.advisory_drift {
1222                println!("    - {} {}", predicate.name, predicate.hash.as_str());
1223            }
1224        }
1225        if !slice.historical_only_predicates.is_empty() {
1226            println!("  historical predicate hashes no longer in current set:");
1227            for hash in &slice.historical_only_predicates {
1228                println!("    - {}", hash.as_str());
1229            }
1230        }
1231    }
1232}
1233
1234fn discovery_diagnostics(
1235    chains: &[Vec<harn_vm::flow::DiscoveredInvariantFile>],
1236) -> Vec<(String, &harn_vm::flow::DiscoveryDiagnostic)> {
1237    chains
1238        .iter()
1239        .flat_map(|chain| chain.iter())
1240        .flat_map(|file| {
1241            file.diagnostics
1242                .iter()
1243                .map(move |diagnostic| (file.path.display().to_string(), diagnostic))
1244        })
1245        .collect()
1246}
1247
1248fn has_discovery_error(diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)]) -> bool {
1249    diagnostics.iter().any(|(_, diagnostic)| {
1250        diagnostic.severity == harn_vm::flow::DiscoveryDiagnosticSeverity::Error
1251    })
1252}
1253
1254fn print_discovery_warnings(diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)]) {
1255    for (path, diagnostic) in diagnostics.iter().filter(|(_, diagnostic)| {
1256        diagnostic.severity == harn_vm::flow::DiscoveryDiagnosticSeverity::Warning
1257    }) {
1258        eprintln!("warning: {path}: {}", diagnostic.message);
1259    }
1260}
1261
1262fn render_discovery_diagnostics(
1263    diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)],
1264) -> String {
1265    diagnostics
1266        .iter()
1267        .map(|(path, diagnostic)| format!("{path}: {}", diagnostic.message))
1268        .collect::<Vec<_>>()
1269        .join("\n")
1270}
1271
1272fn write_json(path: &Path, value: &serde_json::Value) -> Result<(), std::io::Error> {
1273    if let Some(parent) = path
1274        .parent()
1275        .filter(|parent| !parent.as_os_str().is_empty())
1276    {
1277        fs::create_dir_all(parent)?;
1278    }
1279    fs::write(path, serde_json::to_vec_pretty(value).unwrap())
1280}
1281
1282fn print_payload(json_output: bool, text: &str, payload: &serde_json::Value) {
1283    if json_output {
1284        println!("{}", serde_json::to_string_pretty(payload).unwrap());
1285    } else {
1286        println!("{text}");
1287    }
1288}
1289
1290fn parse_since(raw: &str) -> Result<OffsetDateTime, String> {
1291    if let Ok(parsed) = OffsetDateTime::parse(raw, &Rfc3339) {
1292        return Ok(parsed);
1293    }
1294    if let Ok(unix) = raw.parse::<i64>() {
1295        let parsed = if raw.len() > 10 {
1296            OffsetDateTime::from_unix_timestamp_nanos(unix as i128 * 1_000_000)
1297        } else {
1298            OffsetDateTime::from_unix_timestamp(unix)
1299        };
1300        return parsed.map_err(|error| format!("invalid --since timestamp '{raw}': {error}"));
1301    }
1302    let date_format = time::format_description::parse("[year]-[month]-[day]")
1303        .map_err(|error| format!("failed to build date parser: {error}"))?;
1304    let date = Date::parse(raw, &date_format).map_err(|_| {
1305        format!("invalid --since date '{raw}'; use RFC3339, unix time, or YYYY-MM-DD")
1306    })?;
1307    Ok(date.with_time(Time::MIDNIGHT).assume_utc())
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313    use ed25519_dalek::SigningKey;
1314    use harn_vm::flow::{Atom, Provenance};
1315
1316    #[test]
1317    fn parse_since_accepts_rfc3339_unix_and_date() {
1318        assert_eq!(
1319            parse_since("2026-04-26T12:00:00Z")
1320                .unwrap()
1321                .unix_timestamp(),
1322            1_777_204_800
1323        );
1324        assert_eq!(
1325            parse_since("1777205600").unwrap().unix_timestamp(),
1326            1_777_205_600
1327        );
1328        assert_eq!(
1329            parse_since("2026-04-26").unwrap().unix_timestamp(),
1330            1_777_161_600
1331        );
1332    }
1333
1334    #[test]
1335    fn archivist_rust_proposal_is_parseable_harn_with_provenance() {
1336        let temp = tempfile::tempdir().unwrap();
1337        fs::write(
1338            temp.path().join("Cargo.toml"),
1339            "[package]\nname = \"demo\"\n",
1340        )
1341        .unwrap();
1342        fs::create_dir_all(temp.path().join("src")).unwrap();
1343        fs::write(temp.path().join("src/lib.rs"), "pub unsafe fn raw() {}\n").unwrap();
1344
1345        let inventory = inventory_repo(temp.path());
1346        let proposals = archivist_proposals(temp.path(), &inventory, &[], &[], true, "2026-04-26");
1347        let rust = proposals
1348            .iter()
1349            .find(|proposal| proposal.id == "rust-unsafe-safety-comment")
1350            .expect("rust unsafe proposal");
1351
1352        let parsed = harn_vm::flow::parse_invariants_source(&rust.source);
1353        assert!(
1354            parsed.diagnostics.is_empty(),
1355            "generated source should parse cleanly: {:?}",
1356            parsed.diagnostics
1357        );
1358        assert_eq!(
1359            parsed.predicates[0].name,
1360            "rust_unsafe_requires_safety_comment"
1361        );
1362        assert!(parsed.predicates[0].archivist.is_some());
1363    }
1364
1365    #[test]
1366    fn shadow_evaluate_reports_false_positive_atom_pointers() {
1367        let temp = tempfile::tempdir().unwrap();
1368        fs::write(
1369            temp.path().join("Cargo.toml"),
1370            "[package]\nname = \"demo\"\n",
1371        )
1372        .unwrap();
1373        let store_path = temp.path().join(".harn/flow.sqlite");
1374        fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1375
1376        {
1377            let store = SqliteFlowStore::open(&store_path, "test").unwrap();
1378            let principal = SigningKey::from_bytes(&[7; 32]);
1379            let persona = SigningKey::from_bytes(&[8; 32]);
1380            let atom = Atom::sign(
1381                vec![TextOp::Insert {
1382                    offset: 0,
1383                    content: "unsafe { /* SAFETY: fixture */ }".to_string(),
1384                }],
1385                Vec::new(),
1386                Provenance::new("user:test", "archivist-test", "run-1", "trace-1", "tx-1"),
1387                None,
1388                &principal,
1389                &persona,
1390            )
1391            .unwrap();
1392            store.emit_atoms(&[atom]).unwrap();
1393        }
1394
1395        let proposal = rust_unsafe_proposal(temp.path(), "2026-04-26");
1396        let report = shadow_evaluate(temp.path(), &store_path, 30, &[proposal]).unwrap();
1397        assert_eq!(report["status"], "evaluated");
1398        assert_eq!(report["recent_atoms"], 1);
1399        assert_eq!(
1400            report["false_positive_candidates"][0]["transcript_ref"],
1401            "tx-1"
1402        );
1403        assert!(report["false_positive_candidates"][0]["atom"].is_string());
1404    }
1405}