Skip to main content

skill_evidence/
status_reports.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs,
4    path::{Path, PathBuf},
5    process::Command,
6};
7
8use regex::Regex;
9use serde::Serialize;
10use serde_json::Value;
11use sha2::{Digest, Sha256};
12use time::{OffsetDateTime, format_description::well_known::Rfc3339};
13
14use crate::assets;
15
16use crate::gate::{self, EvidenceEvent, GateClock, Outcome, format_timestamp_milliseconds};
17
18use super::{
19    CandidateCluster, Error, Host, SESSION_UNAVAILABLE, hash_target_directory,
20    parse_method_gap_family_selector, read_stream, refusal, target_context, unsafe_failure,
21};
22
23const MAX_LINEAGE_BYTES: u64 = 2 * 1024 * 1024;
24const LINEAGE_EXTENSIONS: &[&str] = &["md", "json", "jsonl", "yaml", "yml"];
25
26#[derive(Debug, Serialize)]
27pub struct MethodGapResearchInventory {
28    schema: String,
29    generated_at: String,
30    family_selector: String,
31    family_prefix: String,
32    canonical_members: usize,
33    targets: Vec<MethodGapTargetInventory>,
34}
35
36#[derive(Debug, Serialize)]
37struct MethodGapTargetInventory {
38    target_name: String,
39    target_path: String,
40    target_content_hash: String,
41    target_file_count: usize,
42    evidence_store: Option<String>,
43    evidence_integrity_errors: Vec<String>,
44    current_evidence: CurrentEvidence,
45    current_candidate_clusters: Vec<CandidateCluster>,
46    /// Open incidents a close retired from the evolution gate as untestable.
47    ///
48    /// `current_candidate_clusters` drops them, because they no longer cluster, while
49    /// `current_evidence` still lists them individually — without this the difference
50    /// between the two reads as an inconsistency rather than a recorded decision.
51    /// Omitted when empty.
52    #[serde(skip_serializing_if = "Vec::is_empty")]
53    instrument_limited_incident_ids: Vec<String>,
54    active_evidence_review_id: Option<String>,
55    last_completed_evidence_review_id: Option<String>,
56    lineage_candidates: Vec<LineageCandidate>,
57    git: GitObservation,
58}
59
60#[derive(Debug, Serialize)]
61struct CurrentEvidence {
62    qualifying_uses: usize,
63    outcome_counts: OutcomeCounts,
64    open_incidents: Vec<OpenIncident>,
65    observed_target_hashes: Vec<ObservedTargetHash>,
66}
67
68#[derive(Debug, Serialize)]
69struct OpenIncident {
70    event_id: String,
71    recorded_at: String,
72    top_level_session_id: String,
73    retrospective: bool,
74    task_label: String,
75    task_fingerprint: String,
76    outcome: String,
77    symptom_key: Option<String>,
78    expected: Option<String>,
79    observed: Option<String>,
80    consequence: Option<String>,
81    workaround_taken: Option<String>,
82    evidence_refs: Vec<String>,
83}
84
85#[derive(Debug, Serialize)]
86struct ObservedTargetHash {
87    content_hash: String,
88    qualifying_uses: usize,
89    first_recorded_at: String,
90    last_recorded_at: String,
91}
92
93#[derive(Debug, Serialize)]
94struct OutcomeCounts {
95    clean: usize,
96    friction: usize,
97    material_failure: usize,
98    severe_incident: usize,
99}
100
101#[derive(Debug, Serialize)]
102struct GitObservation {
103    worktree_status: Option<Vec<String>>,
104    recent_commits: Option<Vec<GitCommit>>,
105    unavailable_reason: Option<String>,
106}
107
108#[derive(Debug, Serialize)]
109struct GitCommit {
110    commit: String,
111    committed_at: String,
112    subject: String,
113}
114
115#[derive(Debug)]
116struct LineageFile {
117    path: String,
118    absolute: PathBuf,
119    size: u64,
120}
121
122#[derive(Debug, Serialize)]
123struct LineageCandidate {
124    path: String,
125    discovery: &'static str,
126    kind: &'static str,
127    size_bytes: u64,
128    line_count: Option<usize>,
129    sha256: Option<String>,
130    header_excerpt: Vec<String>,
131    signal_lines: Vec<String>,
132    unreadable_reason: Option<String>,
133}
134
135pub fn method_gap_research_inventory(
136    root: &Path,
137    selector: &str,
138    now_epoch_milliseconds: i64,
139    host: &Host,
140) -> Result<MethodGapResearchInventory, Error> {
141    let prefix = parse_method_gap_family_selector(selector)?;
142    let generated_at = report_timestamp(now_epoch_milliseconds)?;
143    let root = root.canonicalize().map_err(|error| {
144        unsafe_failure(format!(
145            "Could not resolve repository root {}: {error}",
146            root.display()
147        ))
148    })?;
149    let skills_directory = root.join(".claude/skills");
150    if !skills_directory.is_dir() {
151        return Err(unsafe_failure(format!(
152            "Canonical skill directory not found: {}",
153            skills_directory.display()
154        )));
155    }
156    let mut target_names = fs::read_dir(&skills_directory)
157        .map_err(|error| {
158            unsafe_failure(format!(
159                "Could not read canonical skill directory {}: {error}",
160                skills_directory.display()
161            ))
162        })?
163        .filter_map(Result::ok)
164        .filter(|entry| entry.path().is_dir())
165        .filter_map(|entry| entry.file_name().into_string().ok())
166        .filter(|name| name.starts_with(&prefix))
167        .filter(|name| skills_directory.join(name).join("SKILL.md").is_file())
168        .collect::<Vec<_>>();
169    target_names.sort();
170    if target_names.is_empty() {
171        return Err(unsafe_failure(format!(
172            "No canonical skills match family {selector:?}."
173        )));
174    }
175    let lineage_files = read_lineage_files(&root)?;
176
177    let targets = target_names
178        .into_iter()
179        .map(|target_name| {
180            let target_path = format!(".claude/skills/{target_name}");
181            let target = target_context(&root, &root.join(&target_path))?;
182            let hash = hash_target_directory(&target.target_real)?;
183            let evidence_store = target.evidence_directory.is_dir().then(|| {
184                target
185                    .evidence_directory
186                    .strip_prefix(&root)
187                    .unwrap_or(&target.evidence_directory)
188                    .to_string_lossy()
189                    .replace(std::path::MAIN_SEPARATOR, "/")
190            });
191            let mut stream = read_stream(&target.evidence_directory.join("events.jsonl"))?;
192            match validated_event_target_path(stream.events()) {
193                Ok(Some(event_path)) if event_path != target_path => {
194                    stream = stream.with_integrity_error(format!(
195                        "evidence target path {event_path} does not match canonical target {target_path}"
196                    ));
197                }
198                Ok(_) => {}
199                Err(error) => stream = stream.with_integrity_error(error),
200            }
201            let integrity_errors = stream.integrity_errors().to_vec();
202            let gate = gate::derive(
203                &stream,
204                target.labels(),
205                &hash.content_hash,
206                GateClock {
207                    generated_at: &generated_at,
208                    now_epoch_milliseconds,
209                    session_id: SESSION_UNAVAILABLE,
210                },
211            );
212            Ok(MethodGapTargetInventory {
213                target_name,
214                target_path,
215                target_content_hash: hash.content_hash,
216                target_file_count: hash.file_count,
217                evidence_store,
218                evidence_integrity_errors: integrity_errors,
219                current_evidence: summarize_current_evidence(
220                    stream.events(),
221                    &gate.target_content_hash,
222                    &gate.open_incident_ids,
223                ),
224                current_candidate_clusters: gate.candidate_clusters,
225                instrument_limited_incident_ids: gate.instrument_limited_incident_ids,
226                active_evidence_review_id: gate.active_review_id,
227                last_completed_evidence_review_id: gate.last_completed_review_id,
228                lineage_candidates: lineage_candidates(
229                    &lineage_files,
230                    &target.target_name,
231                    &target.repo_relative_path,
232                ),
233                git: git_observation(&root, &target.repo_relative_path),
234            })
235        })
236        .collect::<Result<Vec<_>, Error>>()?;
237
238    Ok(MethodGapResearchInventory {
239        schema: host.method_gap_inventory_schema(),
240        generated_at,
241        family_selector: selector.to_owned(),
242        family_prefix: prefix,
243        canonical_members: targets.len(),
244        targets,
245    })
246}
247
248pub fn skill_evolution_status(
249    root: &Path,
250    now_epoch_milliseconds: i64,
251    session_id: &str,
252    host: &Host,
253) -> Result<String, Error> {
254    let generated_at = report_timestamp(now_epoch_milliseconds)?;
255    let root = root.canonicalize().map_err(|error| {
256        unsafe_failure(format!(
257            "Could not resolve repository root {}: {error}",
258            root.display()
259        ))
260    })?;
261    let evolution_skill = root.join(".claude/skills/skill-evolution/SKILL.md");
262    if !evolution_skill.is_file() {
263        return Err(unsafe_failure(format!(
264            "Required sibling Skill Evolution not found: {}",
265            evolution_skill.display()
266        )));
267    }
268    let evolution_target = evolution_skill
269        .parent()
270        .expect("Skill Evolution entrypoint has a parent")
271        .canonicalize()
272        .map_err(|error| {
273            unsafe_failure(format!(
274                "Could not resolve sibling Skill Evolution {}: {error}",
275                evolution_skill.display()
276            ))
277        })?;
278    let operating_package =
279        assets::compare_installed_skill_evolution_package(&evolution_target, host)?;
280    let evidence_root = root.join("reports/skill-evidence");
281    let mut stores = if evidence_root.is_dir() {
282        fs::read_dir(&evidence_root)
283            .map_err(|error| {
284                unsafe_failure(format!(
285                    "Could not read evidence-store directory {}: {error}",
286                    evidence_root.display()
287                ))
288            })?
289            .filter_map(Result::ok)
290            .filter(|entry| entry.path().is_dir())
291            .map(|entry| entry.path())
292            .collect::<Vec<_>>()
293    } else {
294        Vec::new()
295    };
296    stores.sort();
297    let stores_scanned = stores.len();
298    let clock = GateClock {
299        generated_at: &generated_at,
300        now_epoch_milliseconds,
301        session_id,
302    };
303    let mut ready = Vec::new();
304    let mut deferred = Vec::new();
305    let mut retired = Vec::new();
306    let mut blocked = Vec::new();
307    let mut indeterminate = Vec::new();
308    let mut omitted = 0;
309    for store in &stores {
310        let store_key = store
311            .file_name()
312            .and_then(|name| name.to_str())
313            .unwrap_or("unavailable")
314            .to_owned();
315        let events_path = store.join("events.jsonl");
316        let has_event_stream = events_path.is_file();
317        let stream = match read_stream(&events_path) {
318            Ok(stream) => stream,
319            Err(error) => {
320                indeterminate.push(EvolutionIndeterminateEntry {
321                    kind: "scan_failure",
322                    store_key,
323                    target_path: None,
324                    errors: vec![error.to_string()],
325                });
326                continue;
327            }
328        };
329        let errors = stream.integrity_errors().to_vec();
330        let (projection, projection_error) = read_gate_projection(&store.join("gate-status.json"));
331        let (event_target_path, identity_error) = match validated_event_target_path(stream.events())
332        {
333            Ok(path) => (path, None),
334            Err(error) => (None, Some(error)),
335        };
336        let target_path = event_target_path.or_else(|| {
337            has_event_stream
338                .then(|| {
339                    projection
340                        .as_ref()
341                        .and_then(|value| value["target_repo_relative_path"].as_str())
342                        .filter(|path| !path.is_empty())
343                        .map(str::to_owned)
344                })
345                .flatten()
346        });
347        let Some(target_path) = target_path else {
348            let has_stream_errors = !errors.is_empty();
349            let mut reported_errors = errors;
350            if let Some(error) = projection_error {
351                reported_errors.push(error);
352            }
353            reported_errors.push(identity_error.unwrap_or_else(|| {
354                if has_event_stream {
355                    "no target path exists in events.jsonl or gate-status.json".to_owned()
356                } else {
357                    "events.jsonl does not exist; a gate projection alone cannot identify an evidence store"
358                        .to_owned()
359                }
360            }));
361            indeterminate.push(EvolutionIndeterminateEntry {
362                kind: if has_stream_errors {
363                    "integrity"
364                } else {
365                    "unidentified_store"
366                },
367                store_key,
368                target_path: None,
369                errors: reported_errors,
370            });
371            continue;
372        };
373        if let Some(identity_error) = identity_error {
374            let mut reported_errors = errors;
375            reported_errors.push(identity_error);
376            indeterminate.push(EvolutionIndeterminateEntry {
377                kind: "integrity",
378                store_key,
379                target_path: Some(target_path),
380                errors: reported_errors,
381            });
382            continue;
383        }
384        if !errors.is_empty() {
385            indeterminate.push(EvolutionIndeterminateEntry {
386                kind: "integrity",
387                store_key,
388                target_path: Some(target_path),
389                errors,
390            });
391            continue;
392        }
393        let recorded_target = Path::new(&target_path);
394        let rooted_target = if recorded_target.is_absolute() {
395            recorded_target.to_owned()
396        } else {
397            root.join(recorded_target)
398        };
399        let target = match target_context(&root, &rooted_target) {
400            Ok(target) => target,
401            Err(error) => {
402                let mut reported_errors = Vec::new();
403                if let Some(projection_error) = projection_error {
404                    reported_errors.push(projection_error);
405                }
406                reported_errors.push(error.to_string());
407                indeterminate.push(EvolutionIndeterminateEntry {
408                    kind: "missing_target",
409                    store_key,
410                    target_path: Some(target_path),
411                    errors: reported_errors,
412                });
413                continue;
414            }
415        };
416        let hash = match hash_target_directory(&target.target_real) {
417            Ok(hash) => hash,
418            Err(error) => {
419                indeterminate.push(EvolutionIndeterminateEntry {
420                    kind: "scan_failure",
421                    store_key,
422                    target_path: None,
423                    errors: vec![error.to_string()],
424                });
425                continue;
426            }
427        };
428        let status = gate::derive(&stream, target.labels(), &hash.content_hash, clock);
429        if status.state == "review_in_progress" {
430            let started = stream.events().iter().rev().find(|event| {
431                event.starts_ownership() && event.review_id() == status.active_review_id.as_deref()
432            });
433            let without_active_start = started.map_or_else(
434                || stream.clone(),
435                |started| stream.without_event(&started.event_id),
436            );
437            let underlying = gate::derive(
438                &without_active_start,
439                target.labels(),
440                &hash.content_hash,
441                clock,
442            );
443            if started.is_some_and(|event| !event.is_review_start())
444                && underlying.authorization_reason.is_none()
445            {
446                omitted += 1;
447                continue;
448            }
449            let quarantined = underlying.authorization_reason.as_deref() == Some("severe");
450            let authorization_reason = started
451                .and_then(|event| {
452                    event
453                        .raw
454                        .pointer("/payload/authorizing_rule")
455                        .and_then(Value::as_str)
456                })
457                .map(str::to_owned)
458                .or(underlying.authorization_reason)
459                .unwrap_or_default();
460            blocked.push(EvolutionBlockedEntry {
461                target_path: target.repo_relative_path,
462                authorization_reason,
463                review_reentry_basis: underlying.review_reentry_basis,
464                instrument_limited_incidents: underlying.instrument_limited_incident_ids.len(),
465                quarantined,
466                kind: EvolutionBlockedKind::ReviewInProgress {
467                    review_id: status.active_review_id.unwrap_or_default(),
468                    started_at: started.map(|event| event.recorded_at.clone()),
469                    operator_workflow: started.and_then(|event| {
470                        event
471                            .raw
472                            .get("operator_workflow")
473                            .and_then(Value::as_str)
474                            .map(str::to_owned)
475                    }),
476                    risk_tier: started.and_then(|event| {
477                        event
478                            .raw
479                            .pointer("/payload/risk_tier")
480                            .and_then(Value::as_str)
481                            .map(str::to_owned)
482                    }),
483                    review_artifacts: format!("{}/reviews", repo_path(&root, store)),
484                },
485            });
486            continue;
487        }
488        if status.authorization_reason.is_none() {
489            let basis = status.review_reentry_basis.as_deref();
490            let instrument_limited_incidents = status.instrument_limited_incident_ids.len();
491            if matches!(
492                basis,
493                Some("queued_pre_close_evidence" | "queued_behind_instrument_limited_review")
494            ) {
495                deferred.push(EvolutionDeferredEntry {
496                    instrument_limited: basis == Some("queued_behind_instrument_limited_review"),
497                    instrument_limited_incidents,
498                    target_path: target.repo_relative_path,
499                });
500                continue;
501            }
502            if instrument_limited_incidents > 0 {
503                retired.push(EvolutionRetiredEntry {
504                    incidents: instrument_limited_incidents,
505                    still_collecting: status
506                        .open_incident_ids
507                        .len()
508                        .saturating_sub(instrument_limited_incidents),
509                    target_path: target.repo_relative_path,
510                });
511                continue;
512            }
513        }
514        if status.authorization_reason.is_some() {
515            let instrument_limited_incidents = status.instrument_limited_incident_ids.len();
516            let quarantined = status.state.starts_with("quarantined_")
517                || status.authorization_reason.as_deref() == Some("severe");
518            let authorization_reason = status.authorization_reason.unwrap_or_default();
519            if target.target_real == evolution_target {
520                let proof = if status.threshold_session_id.is_some() {
521                    SelfTargetProof::DifferentSession
522                } else {
523                    timer_details(status.not_before.as_deref(), now_epoch_milliseconds).map_or(
524                        SelfTargetProof::ClockUnavailable,
525                        |(not_before, remaining_ms)| SelfTargetProof::Clock {
526                            not_before,
527                            remaining_ms,
528                        },
529                    )
530                };
531                blocked.push(EvolutionBlockedEntry {
532                    target_path: target.repo_relative_path,
533                    authorization_reason,
534                    review_reentry_basis: status.review_reentry_basis,
535                    instrument_limited_incidents,
536                    quarantined,
537                    kind: EvolutionBlockedKind::SelfTarget { proof },
538                });
539            } else if status.threshold_session_id.is_some() {
540                if session_id == SESSION_UNAVAILABLE {
541                    blocked.push(EvolutionBlockedEntry {
542                        target_path: target.repo_relative_path,
543                        authorization_reason,
544                        review_reentry_basis: status.review_reentry_basis,
545                        instrument_limited_incidents,
546                        quarantined,
547                        kind: EvolutionBlockedKind::SessionHostRequired,
548                    });
549                } else {
550                    ready.push(EvolutionReadyEntry {
551                        target_path: target.repo_relative_path.clone(),
552                        authorization_reason,
553                        threshold_session_id: status.threshold_session_id,
554                        review_reentry_basis: status.review_reentry_basis,
555                        instrument_limited_incidents,
556                        quarantined,
557                        command: evolution_command(&target.repo_relative_path),
558                        proof: EvolutionReadyProof::DifferentSession,
559                    });
560                }
561            } else if status.threshold_session_id.is_none() {
562                match timer_details(status.not_before.as_deref(), now_epoch_milliseconds) {
563                    Some((not_before, remaining_ms)) if remaining_ms > 0 => {
564                        blocked.push(EvolutionBlockedEntry {
565                            target_path: target.repo_relative_path,
566                            authorization_reason,
567                            review_reentry_basis: status.review_reentry_basis,
568                            instrument_limited_incidents,
569                            quarantined,
570                            kind: EvolutionBlockedKind::Timer {
571                                not_before,
572                                remaining_ms,
573                            },
574                        });
575                    }
576                    Some((not_before, _)) => {
577                        ready.push(EvolutionReadyEntry {
578                            target_path: target.repo_relative_path.clone(),
579                            authorization_reason,
580                            threshold_session_id: None,
581                            review_reentry_basis: status.review_reentry_basis,
582                            instrument_limited_incidents,
583                            quarantined,
584                            command: evolution_command(&target.repo_relative_path),
585                            proof: EvolutionReadyProof::CooldownElapsed { not_before },
586                        });
587                    }
588                    None => omitted += 1,
589                }
590            } else {
591                omitted += 1;
592            }
593        } else {
594            omitted += 1;
595        }
596    }
597    ready.sort_by(|left, right| {
598        right
599            .quarantined
600            .cmp(&left.quarantined)
601            .then_with(|| left.target_path.cmp(&right.target_path))
602    });
603    blocked.sort_by(|left, right| {
604        right
605            .quarantined
606            .cmp(&left.quarantined)
607            .then_with(|| left.not_before().cmp(right.not_before()))
608            .then_with(|| left.target_path.cmp(&right.target_path))
609    });
610    deferred.sort_by(|left, right| left.target_path.cmp(&right.target_path));
611    retired.sort_by(|left, right| left.target_path.cmp(&right.target_path));
612    indeterminate.sort_by(|left, right| {
613        left.target_path
614            .as_deref()
615            .unwrap_or(&left.store_key)
616            .cmp(right.target_path.as_deref().unwrap_or(&right.store_key))
617    });
618    let store_noun = if stores_scanned == 1 {
619        "store"
620    } else {
621        "stores"
622    };
623    // Present only when it has something to report. Every bucket has to appear in the
624    // census or the counts stop summing to the stores scanned — but a bucket that is
625    // always printed would have rewritten `status-reporters-v1`'s golden, and
626    // `docs/principles/inherited-prohibitions.md` forbids editing a fixture corpus to make
627    // a change pass, without qualification. Omitting an empty count leaves the existing
628    // bytes exactly as the JavaScript reporter emitted them.
629    let retired_census = if retired.is_empty() {
630        String::new()
631    } else {
632        format!("retired as untestable: {}; ", retired.len())
633    };
634    let operating_package_notice = if operating_package.matches_shipped {
635        String::new()
636    } else {
637        format!(
638            "Operating package mismatch: {} installed Skill Evolution files differ from the ones this crate ships: {}. Run `{} skills evidence install --root <repository> --force` before claiming a review; active reviews may continue.\n\n",
639            operating_package.differing_files.len(),
640            operating_package.differing_files.join(", "),
641            host.command
642        )
643    };
644    let mut report = format!(
645        "# Skill Evolution Status\n\n{operating_package_notice}Scanned {stores_scanned} evidence {store_noun} read-only. Ready: {}; deferred after review: {}; {retired_census}blocked after eligibility: {}; indeterminate: {}; omitted as not eligible: {omitted}.",
646        ready.len(),
647        deferred.len(),
648        blocked.len(),
649        indeterminate.len()
650    );
651    if ready.is_empty() && blocked.is_empty() {
652        report.push_str("\n\nNo eligible targets found.");
653    }
654    if !ready.is_empty() {
655        report.push_str("\n\n## Ready to evolve\n\n");
656        report.push_str(
657            &ready
658                .iter()
659                .map(|entry| render_ready_entry(entry, session_id))
660                .collect::<Vec<_>>()
661                .join("\n\n"),
662        );
663    }
664    if !deferred.is_empty() {
665        report.push_str("\n\n## Deferred after review\n\n");
666        report.push_str(
667            &deferred
668                .iter()
669                .map(render_deferred_entry)
670                .collect::<Vec<_>>()
671                .join("\n\n"),
672        );
673    }
674    if !retired.is_empty() {
675        report.push_str("\n\n## Retired as untestable\n\n");
676        report.push_str(
677            &retired
678                .iter()
679                .map(render_retired_entry)
680                .collect::<Vec<_>>()
681                .join("\n\n"),
682        );
683    }
684    if !blocked.is_empty() {
685        report.push_str("\n\n## Eligible but blocked\n\n");
686        report.push_str(
687            &blocked
688                .iter()
689                .map(render_blocked_entry)
690                .collect::<Vec<_>>()
691                .join("\n\n"),
692        );
693    }
694    if !indeterminate.is_empty() {
695        report.push_str("\n\n## Could not determine\n\n");
696        report.push_str(
697            &indeterminate
698                .iter()
699                .map(render_indeterminate_entry)
700                .collect::<Vec<_>>()
701                .join("\n\n"),
702        );
703    }
704    report.push('\n');
705    Ok(report)
706}
707
708fn report_timestamp(epoch_milliseconds: i64) -> Result<String, Error> {
709    OffsetDateTime::from_unix_timestamp_nanos(i128::from(epoch_milliseconds) * 1_000_000).map_err(
710        |_| {
711            refusal(format!(
712                "Clock epoch milliseconds are outside the supported range: {epoch_milliseconds}."
713            ))
714        },
715    )?;
716    Ok(format_timestamp_milliseconds(epoch_milliseconds))
717}
718
719fn validated_event_target_path(events: &[EvidenceEvent]) -> Result<Option<String>, String> {
720    let mut paths = Vec::new();
721    for event in events {
722        let Some(path) = event
723            .raw
724            .pointer("/target/repo_relative_path")
725            .and_then(Value::as_str)
726        else {
727            continue;
728        };
729        if !paths.iter().any(|known| known == path) {
730            paths.push(path.to_owned());
731        }
732    }
733    if paths.len() > 1 {
734        Err(format!(
735            "validated events name multiple target paths: {}",
736            paths.join(", ")
737        ))
738    } else {
739        Ok(paths.into_iter().next())
740    }
741}
742
743#[derive(Debug)]
744struct EvolutionReadyEntry {
745    target_path: String,
746    authorization_reason: String,
747    threshold_session_id: Option<String>,
748    review_reentry_basis: Option<String>,
749    instrument_limited_incidents: usize,
750    quarantined: bool,
751    command: String,
752    proof: EvolutionReadyProof,
753}
754
755#[derive(Debug)]
756enum EvolutionReadyProof {
757    DifferentSession,
758    CooldownElapsed { not_before: String },
759}
760
761/// A target whose threshold-supporting evidence is held behind the last completed
762/// same-hash review.
763#[derive(Debug)]
764struct EvolutionDeferredEntry {
765    target_path: String,
766    /// Whether the review that laid the watermark reached a conclusion. A review that
767    /// adjudicated accounted for the evidence it saw; one that closed
768    /// `blocked_no_valid_test` accounted for nothing, and saying otherwise reports
769    /// unexamined evidence as handled.
770    instrument_limited: bool,
771    instrument_limited_incidents: usize,
772}
773
774fn render_deferred_entry(entry: &EvolutionDeferredEntry) -> String {
775    let mut rendered = format!("### {}\n\n", entry.target_path);
776    if entry.instrument_limited {
777        rendered.push_str(
778            "- Re-entry basis: queued behind a review whose instrument could not test what it covered; that close reached no conclusion and adjudicated nothing, so this evidence is unexamined rather than accounted for.\n",
779        );
780    } else {
781        rendered.push_str(
782            "- Re-entry basis: queued pre-close evidence only; no threshold-supporting incident was recorded after the last completed same-hash review.\n",
783        );
784    }
785    if let Some(line) = retired_line(entry.instrument_limited_incidents) {
786        rendered.push_str(&line);
787        rendered.push('\n');
788    }
789    rendered.push_str(
790        "- Gate: Still collecting. Skill Evolution is not authorized, and no command is available.",
791    );
792    rendered
793}
794
795/// A target whose open evidence a review could not decide, so it no longer drives the
796/// gate — whether the review carried an instrument-limited disposition or named that
797/// coverage as untestable inside an adjudicating close.
798///
799/// Without its own section these targets fall into `omitted as not eligible`, which is
800/// where skills that never recorded an incident go. Reporting real retired evidence as
801/// indistinguishable from no evidence is the silence this section exists to break.
802#[derive(Debug)]
803struct EvolutionRetiredEntry {
804    target_path: String,
805    incidents: usize,
806    /// Open incidents the close did not cover, still short of a threshold. Without this
807    /// the entry reads as a closed book, when the target may be one incident from
808    /// authorizing a review.
809    still_collecting: usize,
810}
811
812/// The one place a count meets a noun. Every sentence built on it refers back with
813/// "that evidence" rather than a pronoun or verb that would have to agree, because the
814/// ten-use threshold can fire on a single incident and a blocked close can retire it.
815fn open_incidents(count: usize) -> String {
816    let noun = if count == 1 { "incident" } else { "incidents" };
817    format!("{count} open {noun}")
818}
819
820fn render_retired_entry(entry: &EvolutionRetiredEntry) -> String {
821    let mut rendered = format!(
822        "### {}\n\n- Retired as untestable: {}, covered by a review that could not decide it. That evidence no longer drives this gate, and remains in the event stream.\n",
823        entry.target_path,
824        open_incidents(entry.incidents)
825    );
826    if entry.still_collecting > 0 {
827        rendered.push_str(&format!(
828            "- Still collecting: {} the close did not cover, short of a threshold.\n",
829            open_incidents(entry.still_collecting)
830        ));
831    }
832    rendered.push_str(
833        "- Gate: Skill Evolution is not authorized, and no command is available. Another review of the same evidence would meet the same instrument.",
834    );
835    rendered
836}
837
838#[derive(Debug)]
839struct EvolutionIndeterminateEntry {
840    kind: &'static str,
841    store_key: String,
842    target_path: Option<String>,
843    errors: Vec<String>,
844}
845
846fn render_indeterminate_entry(entry: &EvolutionIndeterminateEntry) -> String {
847    let label = entry
848        .target_path
849        .clone()
850        .unwrap_or_else(|| format!("evidence store {}", entry.store_key));
851    let mut lines = vec![
852        format!("### {label}"),
853        String::new(),
854        format!("- Status: `{}`.", entry.kind),
855    ];
856    lines.extend(entry.errors.iter().map(|error| format!("- {error}")));
857    lines.join("\n")
858}
859
860fn read_gate_projection(path: &Path) -> (Option<Value>, Option<String>) {
861    let bytes = match fs::read(path) {
862        Ok(bytes) => bytes,
863        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return (None, None),
864        Err(error) => {
865            return (
866                None,
867                Some(format!("Could not read gate-status.json: {error}")),
868            );
869        }
870    };
871    match serde_json::from_slice(&bytes) {
872        Ok(value) => (Some(value), None),
873        Err(error) => (
874            None,
875            Some(format!("gate-status.json is not valid JSON: {error}")),
876        ),
877    }
878}
879
880#[derive(Debug)]
881struct EvolutionBlockedEntry {
882    target_path: String,
883    authorization_reason: String,
884    review_reentry_basis: Option<String>,
885    instrument_limited_incidents: usize,
886    quarantined: bool,
887    kind: EvolutionBlockedKind,
888}
889
890impl EvolutionBlockedEntry {
891    fn not_before(&self) -> &str {
892        match &self.kind {
893            EvolutionBlockedKind::Timer { not_before, .. } => not_before,
894            EvolutionBlockedKind::SessionHostRequired => "",
895            EvolutionBlockedKind::ReviewInProgress { .. } => "",
896            EvolutionBlockedKind::SelfTarget { .. } => "",
897        }
898    }
899}
900
901#[derive(Debug)]
902enum EvolutionBlockedKind {
903    Timer {
904        not_before: String,
905        remaining_ms: i64,
906    },
907    SessionHostRequired,
908    ReviewInProgress {
909        review_id: String,
910        started_at: Option<String>,
911        operator_workflow: Option<String>,
912        risk_tier: Option<String>,
913        review_artifacts: String,
914    },
915    SelfTarget {
916        proof: SelfTargetProof,
917    },
918}
919
920#[derive(Debug)]
921enum SelfTargetProof {
922    DifferentSession,
923    Clock {
924        not_before: String,
925        remaining_ms: i64,
926    },
927    ClockUnavailable,
928}
929
930fn evolution_command(target_path: &str) -> String {
931    let escaped = target_path.replace('\\', "\\\\").replace('"', "\\\"");
932    format!("$skill-evolution \"{escaped}\"")
933}
934
935fn render_blocked_entry(entry: &EvolutionBlockedEntry) -> String {
936    let mut lines = vec![
937        format!(
938            "### {}{}",
939            entry.target_path,
940            if entry.quarantined {
941                " — QUARANTINED"
942            } else {
943                ""
944            }
945        ),
946        String::new(),
947        format!(
948            "- Eligibility: {}.",
949            explain_authorization_reason(&entry.authorization_reason)
950        ),
951    ];
952    if let Some(line) = reentry_line(entry.review_reentry_basis.as_deref()) {
953        lines.push(line.to_owned());
954    }
955    if let Some(line) = retired_line(entry.instrument_limited_incidents) {
956        lines.push(line);
957    }
958    match &entry.kind {
959        EvolutionBlockedKind::Timer {
960            not_before,
961            remaining_ms,
962        } => {
963            lines.push(format!(
964                "- Blocker: 12-hour clock fallback; {} remaining.",
965                format_duration(*remaining_ms)
966            ));
967            lines.push(format!(
968                "- Eligible at: {} (UTC); {not_before}.",
969                format_local_utc(not_before)
970            ));
971            lines
972                .push("- Session effect: Changing sessions will not bypass this timer.".to_owned());
973        }
974        EvolutionBlockedKind::SessionHostRequired => {
975            lines.push("- Blocker: This threshold was recorded with a top-level-session identity, so its destination proof needs a session-ID-capable host. The current host exposes no top-level-session identity.".to_owned());
976            lines.push("- Route: Rerun this read-only status census from a session-ID-capable host — Claude Code (`CLAUDE_CODE_SESSION_ID`) or Codex (`CODEX_THREAD_ID`) — whose identity differs from the threshold session. Waiting will not help; the 12-hour clock never applies to a session-ID threshold.".to_owned());
977        }
978        EvolutionBlockedKind::ReviewInProgress {
979            review_id,
980            started_at,
981            operator_workflow,
982            risk_tier,
983            review_artifacts,
984        } => {
985            lines.push(format!(
986                "- Blocker: Active review `{review_id}` already owns the target."
987            ));
988            if let Some(started_at) = started_at {
989                lines.push(format!(
990                    "- Claimed at: {} (UTC); {started_at}.",
991                    format_local_utc(started_at)
992                ));
993            }
994            if let Some(operator_workflow) = operator_workflow {
995                lines.push(format!(
996                    "- Owner workflow: `{operator_workflow}`{}.",
997                    risk_tier
998                        .as_ref()
999                        .map(|risk| format!("; risk tier `{risk}`"))
1000                        .unwrap_or_default()
1001                ));
1002            }
1003            lines.push(format!("- Review artifacts: `{review_artifacts}`."));
1004        }
1005        EvolutionBlockedKind::SelfTarget { proof } => {
1006            lines.push(
1007                "- Blocker: Skill Evolution cannot target itself, so no `$skill-evolution` command is valid."
1008                    .to_owned(),
1009            );
1010            lines.push(
1011                "- Route: Give the bounded evidence packet to an independent skill-authoring workflow."
1012                    .to_owned(),
1013            );
1014            match proof {
1015                SelfTargetProof::Clock {
1016                    not_before,
1017                    remaining_ms,
1018                } if *remaining_ms > 0 => lines.push(format!(
1019                    "- Additional clock blocker: {} remaining; {not_before}.",
1020                    format_duration(*remaining_ms)
1021                )),
1022                SelfTargetProof::DifferentSession => lines.push(
1023                    "- Session note: A different session can satisfy freshness but cannot remove the self-target prohibition."
1024                        .to_owned(),
1025                ),
1026                SelfTargetProof::Clock { .. } | SelfTargetProof::ClockUnavailable => {}
1027            }
1028        }
1029    }
1030    if entry.quarantined {
1031        lines.push("- Quarantine: Stop using this target. Immediate containment is allowed; permanent edits still require the authorized review.".to_owned());
1032    }
1033    lines.join("\n")
1034}
1035
1036fn timer_details(not_before: Option<&str>, now_epoch_milliseconds: i64) -> Option<(String, i64)> {
1037    let not_before = not_before?;
1038    let parsed = OffsetDateTime::parse(not_before, &Rfc3339).ok()?;
1039    let not_before_milliseconds = i64::try_from(parsed.unix_timestamp_nanos() / 1_000_000).ok()?;
1040    Some((
1041        format_timestamp_milliseconds(not_before_milliseconds),
1042        (not_before_milliseconds - now_epoch_milliseconds).max(0),
1043    ))
1044}
1045
1046fn format_duration(milliseconds: i64) -> String {
1047    if milliseconds <= 0 {
1048        return "0m".to_owned();
1049    }
1050    let minutes = (milliseconds + 60_000 - 1) / 60_000;
1051    let days = minutes / (24 * 60);
1052    let hours = (minutes % (24 * 60)) / 60;
1053    let mins = minutes % 60;
1054    let mut parts = Vec::new();
1055    if days > 0 {
1056        parts.push(format!("{days}d"));
1057    }
1058    if hours > 0 || days > 0 {
1059        parts.push(if days > 0 {
1060            format!("{hours:02}h")
1061        } else {
1062            format!("{hours}h")
1063        });
1064    }
1065    parts.push(if hours > 0 || days > 0 {
1066        format!("{mins:02}m")
1067    } else {
1068        format!("{mins}m")
1069    });
1070    parts.join(" ")
1071}
1072
1073fn format_local_utc(timestamp: &str) -> String {
1074    let parsed = OffsetDateTime::parse(timestamp, &Rfc3339)
1075        .expect("validated gate timestamp parses as RFC 3339");
1076    let format = time::format_description::parse_borrowed::<3>(
1077        "[day]/[month]/[year], [hour]:[minute]:[second] UTC",
1078    )
1079    .expect("static UTC display format");
1080    parsed.format(&format).expect("UTC timestamp formats")
1081}
1082
1083fn render_ready_entry(entry: &EvolutionReadyEntry, census_session_id: &str) -> String {
1084    let mut lines = vec![
1085        format!(
1086            "### {}{}",
1087            entry.target_path,
1088            if entry.quarantined {
1089                " — QUARANTINED"
1090            } else {
1091                ""
1092            }
1093        ),
1094        String::new(),
1095        format!(
1096            "- Eligibility: {}.",
1097            explain_authorization_reason(&entry.authorization_reason)
1098        ),
1099    ];
1100    if let Some(line) = reentry_line(entry.review_reentry_basis.as_deref()) {
1101        lines.push(line.to_owned());
1102    }
1103    if let Some(line) = retired_line(entry.instrument_limited_incidents) {
1104        lines.push(line);
1105    }
1106    match &entry.proof {
1107        EvolutionReadyProof::DifferentSession => {
1108            lines.push("- Destination proof: Paste into a session-ID-capable fresh session (Claude Code or Codex) whose top-level-session identity differs from the threshold session. A no-ID destination will be refused; waiting will not help.".to_owned());
1109            if entry.threshold_session_id.as_deref() == Some(census_session_id) {
1110                lines.push(format!(
1111                    "- This census session recorded the threshold (`{census_session_id}`), so it cannot be the destination; Skill Evolution would refuse here with `refused_cooldown_or_same_session`. The destination proof above still governs which session qualifies."
1112                ));
1113            }
1114        }
1115        EvolutionReadyProof::CooldownElapsed { not_before } => lines.push(format!(
1116            "- Destination proof: The 12-hour clock proof already passed at {} (UTC); {not_before}. A destination session ID is not required.",
1117            format_local_utc(not_before)
1118        )),
1119    }
1120    if entry.quarantined {
1121        lines.push("- Quarantine: Stop using this target. Immediate containment is allowed; permanent edits still require the authorized review.".to_owned());
1122    }
1123    lines.extend([
1124        String::new(),
1125        "Paste into another top-level session:".to_owned(),
1126        String::new(),
1127        "```text".to_owned(),
1128        entry.command.clone(),
1129        "```".to_owned(),
1130    ]);
1131    lines.join("\n")
1132}
1133
1134fn explain_authorization_reason(reason: &str) -> String {
1135    if reason == "severe" {
1136        return "one contemporaneous severe incident (`severe`)".to_owned();
1137    }
1138    if reason == "ten_use_unresolved" {
1139        return "ten qualifying uses on the unchanged target with an open contemporaneous incident (`ten_use_unresolved`)".to_owned();
1140    }
1141    if let Some(symptom) = reason.strip_prefix("material_recurrence:") {
1142        return format!(
1143            "two independent material-failure-or-worse incidents in the `{symptom}` symptom cluster (`{reason}`)"
1144        );
1145    }
1146    if let Some(symptom) = reason.strip_prefix("friction_recurrence:") {
1147        return format!(
1148            "three independent friction-or-worse incidents in the `{symptom}` symptom cluster (`{reason}`)"
1149        );
1150    }
1151    if reason.is_empty() {
1152        "authorization rule unavailable".to_owned()
1153    } else {
1154        format!("gate rule `{reason}`")
1155    }
1156}
1157
1158/// A target can be actionable on new evidence and still carry evidence an earlier
1159/// blocked close retired. Saying so here keeps the census from implying the gate holds
1160/// everything that was ever recorded against this target.
1161fn retired_line(count: usize) -> Option<String> {
1162    (count > 0).then(|| {
1163        format!(
1164            "- Retired as untestable: {}. An earlier close could not decide that evidence, and removed it from this gate.",
1165            open_incidents(count)
1166        )
1167    })
1168}
1169
1170fn reentry_line(basis: Option<&str>) -> Option<&'static str> {
1171    match basis {
1172        Some("first_eligibility") => Some(
1173            "- Gate provenance: First eligibility on this target hash; no completed same-hash review precedes this threshold.",
1174        ),
1175        Some("post_review_incident") => Some(
1176            "- Gate provenance: A new incident was recorded after the last completed same-hash review.",
1177        ),
1178        Some("unadjudicated_severe") => Some(
1179            "- Gate provenance: An earlier unadjudicated severe incident remains quarantined across the later review disposition.",
1180        ),
1181        _ => None,
1182    }
1183}
1184
1185fn summarize_current_evidence(
1186    events: &[EvidenceEvent],
1187    current_hash: &str,
1188    open_incident_ids: &[String],
1189) -> CurrentEvidence {
1190    let current_uses = events
1191        .iter()
1192        .filter(|event| event.use_recorded().is_some() && event.target_content_hash == current_hash)
1193        .collect::<Vec<_>>();
1194    let mut outcome_counts = OutcomeCounts {
1195        clean: 0,
1196        friction: 0,
1197        material_failure: 0,
1198        severe_incident: 0,
1199    };
1200    for event in &current_uses {
1201        match event.use_recorded().expect("filtered use record").outcome {
1202            Outcome::Clean => outcome_counts.clean += 1,
1203            Outcome::Friction => outcome_counts.friction += 1,
1204            Outcome::MaterialFailure => outcome_counts.material_failure += 1,
1205            Outcome::SevereIncident => outcome_counts.severe_incident += 1,
1206        }
1207    }
1208    let open_incidents = current_uses
1209        .iter()
1210        .filter(|event| open_incident_ids.contains(&event.event_id))
1211        .map(|event| {
1212            let payload = &event.raw["payload"];
1213            OpenIncident {
1214                event_id: event.event_id.clone(),
1215                recorded_at: event.recorded_at.clone(),
1216                top_level_session_id: event.top_level_session_id.clone(),
1217                retrospective: payload["retrospective"].as_bool().unwrap_or(false),
1218                task_label: string_field(payload, "task_label"),
1219                task_fingerprint: string_field(payload, "task_fingerprint"),
1220                outcome: string_field(payload, "outcome"),
1221                symptom_key: optional_string_field(payload, "symptom_key"),
1222                expected: optional_string_field(payload, "expected"),
1223                observed: optional_string_field(payload, "observed"),
1224                consequence: optional_string_field(payload, "consequence"),
1225                workaround_taken: optional_string_field(payload, "workaround_taken"),
1226                evidence_refs: payload["evidence_refs"]
1227                    .as_array()
1228                    .into_iter()
1229                    .flatten()
1230                    .filter_map(Value::as_str)
1231                    .map(str::to_owned)
1232                    .collect(),
1233            }
1234        })
1235        .collect();
1236
1237    // `qualifying_uses` is the gate projection's term and carries its meaning: a run, not a
1238    // record. One run can record several incidents, so this tally deduplicates on the run
1239    // group within a hash — the same rule `derive_gate` applies to the current hash, and a
1240    // reporter counting records instead would publish a different number than
1241    // `gate-status.json` under the same name. Outcome counts stay per record: they describe
1242    // the incidents, not how much the target was exercised.
1243    //
1244    // Counted once, for every hash, and the current hash reads its answer off the same rows
1245    // rather than recomputing it. Two tallies of one rule are two places for it to drift.
1246    let mut observed_target_hashes = Vec::<ObservedTargetHash>::new();
1247    let mut positions = HashMap::<String, usize>::new();
1248    let mut counted_run_groups = HashSet::new();
1249    for event in events.iter().filter(|event| event.use_recorded().is_some()) {
1250        let fresh_run = counted_run_groups.insert((
1251            event.target_content_hash.as_str(),
1252            event
1253                .use_recorded()
1254                .expect("filtered use record")
1255                .same_run_group
1256                .as_str(),
1257        ));
1258        if let Some(index) = positions.get(&event.target_content_hash).copied() {
1259            let row = &mut observed_target_hashes[index];
1260            row.qualifying_uses += usize::from(fresh_run);
1261            row.last_recorded_at.clone_from(&event.recorded_at);
1262        } else {
1263            positions.insert(
1264                event.target_content_hash.clone(),
1265                observed_target_hashes.len(),
1266            );
1267            observed_target_hashes.push(ObservedTargetHash {
1268                content_hash: event.target_content_hash.clone(),
1269                qualifying_uses: usize::from(fresh_run),
1270                first_recorded_at: event.recorded_at.clone(),
1271                last_recorded_at: event.recorded_at.clone(),
1272            });
1273        }
1274    }
1275    let qualifying_uses = observed_target_hashes
1276        .iter()
1277        .find(|row| row.content_hash == current_hash)
1278        .map_or(0, |row| row.qualifying_uses);
1279
1280    CurrentEvidence {
1281        qualifying_uses,
1282        outcome_counts,
1283        open_incidents,
1284        observed_target_hashes,
1285    }
1286}
1287
1288fn string_field(value: &Value, key: &str) -> String {
1289    value[key].as_str().unwrap_or_default().to_owned()
1290}
1291
1292fn optional_string_field(value: &Value, key: &str) -> Option<String> {
1293    value[key].as_str().map(str::to_owned)
1294}
1295
1296fn read_lineage_files(root: &Path) -> Result<Vec<LineageFile>, Error> {
1297    let reports = root.join("reports");
1298    if !reports.exists() {
1299        return Ok(Vec::new());
1300    }
1301    let evidence_root = reports.join("skill-evidence");
1302    let mut pending = vec![reports];
1303    let mut files = Vec::new();
1304    while let Some(directory) = pending.pop() {
1305        let entries = fs::read_dir(&directory).map_err(|error| {
1306            unsafe_failure(format!(
1307                "Could not read lineage directory {}: {error}",
1308                directory.display()
1309            ))
1310        })?;
1311        for entry in entries {
1312            let entry = entry.map_err(|error| {
1313                unsafe_failure(format!(
1314                    "Could not read lineage entry in {}: {error}",
1315                    directory.display()
1316                ))
1317            })?;
1318            let path = entry.path();
1319            if path == evidence_root {
1320                continue;
1321            }
1322            let metadata = entry.metadata().map_err(|error| {
1323                unsafe_failure(format!(
1324                    "Could not inspect lineage path {}: {error}",
1325                    path.display()
1326                ))
1327            })?;
1328            if metadata.is_dir() {
1329                pending.push(path);
1330                continue;
1331            }
1332            let extension = path.extension().and_then(|value| value.to_str());
1333            if extension.is_some_and(|value| LINEAGE_EXTENSIONS.contains(&value)) {
1334                files.push(LineageFile {
1335                    path: repo_path(root, &path),
1336                    absolute: path,
1337                    size: metadata.len(),
1338                });
1339            }
1340        }
1341    }
1342    files.sort_by(|left, right| left.path.cmp(&right.path));
1343    Ok(files)
1344}
1345
1346fn lineage_candidates(
1347    files: &[LineageFile],
1348    target_name: &str,
1349    target_path: &str,
1350) -> Vec<LineageCandidate> {
1351    let lower_target_name = target_name.to_ascii_lowercase();
1352    let mut candidates = files
1353        .iter()
1354        .filter_map(|file| {
1355            let lower_name = file
1356                .absolute
1357                .file_name()
1358                .and_then(|name| name.to_str())
1359                .unwrap_or_default()
1360                .to_ascii_lowercase();
1361            let filename_match = lower_name.contains(&format!("{lower_target_name}-method-gap"));
1362            let content_match = !filename_match
1363                && file.size <= MAX_LINEAGE_BYTES
1364                && fs::read_to_string(&file.absolute).is_ok_and(|source| {
1365                    source.lines().any(|line| {
1366                        lineage_identifying_line(line)
1367                            && line.to_ascii_lowercase().contains("method-gap")
1368                            && (line.contains(target_path)
1369                                || line
1370                                    .to_ascii_lowercase()
1371                                    .contains(&format!("`{lower_target_name}`"))
1372                                || contains_exact_target_name(
1373                                    &line.to_ascii_lowercase(),
1374                                    &lower_target_name,
1375                                ))
1376                    })
1377                });
1378            (filename_match || content_match).then(|| {
1379                summarize_lineage_file(
1380                    file,
1381                    if filename_match {
1382                        "filename"
1383                    } else {
1384                        "content"
1385                    },
1386                )
1387            })
1388        })
1389        .collect::<Vec<_>>();
1390    candidates.sort_by(|left, right| left.path.cmp(&right.path));
1391    candidates
1392}
1393
1394fn lineage_identifying_line(line: &str) -> bool {
1395    let line = line.trim_start();
1396    Regex::new(
1397        r"(?i)^(#{1,6}\s|[-*]\s*(?:target|audited|audit target|skill|method-gap disposition)\b|(?:target|audited|audit target|skill|method-gap disposition)\b)",
1398    )
1399    .expect("static lineage identity regex")
1400    .is_match(line)
1401}
1402
1403fn contains_exact_target_name(line: &str, target_name: &str) -> bool {
1404    line.match_indices(target_name).any(|(index, _)| {
1405        let before = line[..index].chars().next_back();
1406        let after = line[index + target_name.len()..].chars().next();
1407        !before.is_some_and(is_skill_name_character) && !after.is_some_and(is_skill_name_character)
1408    })
1409}
1410
1411fn is_skill_name_character(character: char) -> bool {
1412    character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
1413}
1414
1415fn summarize_lineage_file(file: &LineageFile, discovery: &'static str) -> LineageCandidate {
1416    match fs::read(&file.absolute) {
1417        Ok(bytes) => {
1418            let source = String::from_utf8_lossy(&bytes);
1419            let lines = source.split('\n').collect::<Vec<_>>();
1420            let header_excerpt = numbered_lines(&lines, 0..lines.len().min(24));
1421            let signal = Regex::new(
1422                r"(?i)\b(status|lineage|in[- ]flight|returned|adopt(?:ed|ion)?|implement(?:ed|ation)?|land(?:ed|ing)?|defer(?:red|ral)?|reject(?:ed|ion)?|consume(?:d|r|ption)?|disposition|supersed(?:ed|ing)|reopen(?:ed|ing)?|completion)\b",
1423            )
1424            .expect("static lineage signal regex");
1425            let signal_indexes = lines
1426                .iter()
1427                .enumerate()
1428                .filter_map(|(index, line)| signal.is_match(line).then_some(index))
1429                .take(24)
1430                .collect::<Vec<_>>();
1431            LineageCandidate {
1432                path: file.path.clone(),
1433                discovery,
1434                kind: lineage_kind(&file.path),
1435                size_bytes: bytes.len() as u64,
1436                line_count: Some(lines.len()),
1437                sha256: Some(format!("{:x}", Sha256::digest(&bytes))),
1438                header_excerpt,
1439                signal_lines: numbered_lines(&lines, signal_indexes),
1440                unreadable_reason: None,
1441            }
1442        }
1443        Err(error) => LineageCandidate {
1444            path: file.path.clone(),
1445            discovery,
1446            kind: lineage_kind(&file.path),
1447            size_bytes: file.size,
1448            line_count: None,
1449            sha256: None,
1450            header_excerpt: Vec::new(),
1451            signal_lines: Vec::new(),
1452            unreadable_reason: Some(error.to_string()),
1453        },
1454    }
1455}
1456
1457fn lineage_kind(path: &str) -> &'static str {
1458    let name = Path::new(path)
1459        .file_name()
1460        .and_then(|name| name.to_str())
1461        .unwrap_or_default()
1462        .to_ascii_lowercase();
1463    if name.contains("method-gap-census") {
1464        "census"
1465    } else if name.contains("method-gap-research-brief") {
1466        "brief"
1467    } else if name.contains("method-gap-research-report")
1468        || name.contains("method-gap-audit-report")
1469        || Regex::new(r"method-gap-audit\.[^.]+$")
1470            .expect("static returned-audit filename regex")
1471            .is_match(&name)
1472    {
1473        "returned_report"
1474    } else {
1475        "other"
1476    }
1477}
1478
1479fn numbered_lines(lines: &[&str], indexes: impl IntoIterator<Item = usize>) -> Vec<String> {
1480    indexes
1481        .into_iter()
1482        .map(|index| {
1483            let line = lines[index];
1484            let text = line.chars().take(500).collect::<String>();
1485            let suffix = if line.chars().count() > 500 {
1486                " … [truncated]"
1487            } else {
1488                ""
1489            };
1490            format!("{}: {text}{suffix}", index + 1)
1491        })
1492        .collect()
1493}
1494
1495fn git_observation(root: &Path, target_path: &str) -> GitObservation {
1496    let status = git_output(
1497        root,
1498        &[
1499            "status",
1500            "--porcelain=v1",
1501            "--untracked-files=all",
1502            "--",
1503            target_path,
1504        ],
1505    );
1506    let log = git_output(
1507        root,
1508        &["log", "-20", "--format=%H%x09%cI%x09%s", "--", target_path],
1509    );
1510    let worktree_status = status
1511        .as_ref()
1512        .ok()
1513        .filter(|output| output.status.success())
1514        .map(|output| nonempty_lines(&output.stdout));
1515    let recent_commits = log
1516        .as_ref()
1517        .ok()
1518        .filter(|output| output.status.success())
1519        .map(|output| {
1520            nonempty_lines(&output.stdout)
1521                .into_iter()
1522                .map(|line| {
1523                    let mut fields = line.split('\t');
1524                    GitCommit {
1525                        commit: fields.next().unwrap_or_default().to_owned(),
1526                        committed_at: fields.next().unwrap_or_default().to_owned(),
1527                        subject: fields.collect::<Vec<_>>().join("\t"),
1528                    }
1529                })
1530                .collect()
1531        });
1532    let unavailable = [
1533        git_error("status", status.as_ref()),
1534        git_error("log", log.as_ref()),
1535    ]
1536    .into_iter()
1537    .flatten()
1538    .collect::<Vec<_>>();
1539    GitObservation {
1540        worktree_status,
1541        recent_commits,
1542        unavailable_reason: (!unavailable.is_empty()).then(|| unavailable.join("; ")),
1543    }
1544}
1545
1546fn git_output(root: &Path, arguments: &[&str]) -> std::io::Result<std::process::Output> {
1547    Command::new("git")
1548        .arg("-C")
1549        .arg(root)
1550        .args(arguments)
1551        .env("LC_ALL", "C")
1552        .env("TZ", "UTC")
1553        .env("GIT_OPTIONAL_LOCKS", "0")
1554        .output()
1555}
1556
1557fn git_error(
1558    label: &str,
1559    result: Result<&std::process::Output, &std::io::Error>,
1560) -> Option<String> {
1561    match result {
1562        Ok(output) if output.status.success() => None,
1563        Ok(output) => Some(format!(
1564            "{label}: {}",
1565            String::from_utf8_lossy(&output.stderr).trim()
1566        )),
1567        Err(error) => Some(format!("{label}: {error}")),
1568    }
1569}
1570
1571fn nonempty_lines(bytes: &[u8]) -> Vec<String> {
1572    String::from_utf8_lossy(bytes)
1573        .lines()
1574        .filter(|line| !line.is_empty())
1575        .map(str::to_owned)
1576        .collect()
1577}
1578
1579fn repo_path(root: &Path, path: &Path) -> String {
1580    path.strip_prefix(root)
1581        .unwrap_or(path)
1582        .to_string_lossy()
1583        .replace(std::path::MAIN_SEPARATOR, "/")
1584}