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 #[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 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#[derive(Debug)]
764struct EvolutionDeferredEntry {
765 target_path: String,
766 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#[derive(Debug)]
803struct EvolutionRetiredEntry {
804 target_path: String,
805 incidents: usize,
806 still_collecting: usize,
810}
811
812fn 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
1158fn 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 ¤t_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 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}