1use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24use serde_json::{json, Map, Value};
25
26use crate::budget::py_slice_to;
27use crate::identity::artifact_identifier;
28use crate::pycompat::{py_casefold, py_strip, read_text_universal};
29use crate::relationships::{
30 classify_scope_entry, corpus_items, extract_relationships_full, normalized_scope_path,
31 relationships_from_corpus, CorpusItem, Relationship,
32};
33use crate::resolve::{
34 artifact_status, index_from_items, is_live_decision, is_retired_status, search_index,
35 IndexEntry, SearchResult,
36};
37
38pub const DEFAULT_TOP_K: i64 = 5;
40
41const SUPERSEDES: &str = "supersedes";
42const DECISION_TYPE: &str = "decision";
43
44const CHANNEL_KEYWORD: &str = "keyword";
46const CHANNEL_SCOPE: &str = "scope";
47const CHANNEL_SUPERSEDES: &str = "supersedes";
48
49fn pure_posix_parts(text: &str) -> (Option<&'static str>, Vec<String>) {
58 let root = if text.starts_with('/') {
59 if text.starts_with("//") && !text.starts_with("///") {
62 Some("//")
63 } else {
64 Some("/")
65 }
66 } else {
67 None
68 };
69 let parts = text
70 .split('/')
71 .filter(|p| !p.is_empty() && *p != ".")
72 .map(str::to_string)
73 .collect();
74 (root, parts)
75}
76
77fn repository_root(directory: &str) -> PathBuf {
80 let resolved = Path::new(directory).canonicalize().unwrap_or_else(|_| {
81 std::env::current_dir()
83 .map(|c| c.join(directory))
84 .unwrap_or_else(|_| PathBuf::from(directory))
85 });
86 for candidate in resolved.ancestors() {
87 if candidate.join(".decided").join("config.yaml").is_file() {
88 return candidate.to_path_buf();
89 }
90 }
91 resolved
92}
93
94#[derive(Debug, Clone)]
99enum ClassItem {
100 Ch(char),
101 Range(char, char),
102 Digit,
103 NonDigit,
104 Word,
105 NonWord,
106 Space,
107 NonSpace,
108}
109
110#[derive(Debug, Clone)]
111enum GlobTok {
112 Lit(char),
113 Star,
115 Q,
117 SegStar,
119 DotStar,
121 Class {
122 negated: bool,
123 items: Vec<ClassItem>,
124 },
125}
126
127fn compile_glob(pattern: &str) -> Vec<GlobTok> {
129 let chars: Vec<char> = pattern.chars().collect();
130 let n = chars.len();
131 let mut out: Vec<GlobTok> = Vec::new();
132 let mut i = 0usize;
133 while i < n {
134 let c = chars[i];
135 if c == '*' {
136 if i + 1 < n && chars[i + 1] == '*' {
137 i += 2;
138 if i < n && chars[i] == '/' {
139 i += 1;
140 out.push(GlobTok::SegStar);
141 } else {
142 out.push(GlobTok::DotStar);
143 }
144 continue;
145 }
146 out.push(GlobTok::Star);
147 } else if c == '?' {
148 out.push(GlobTok::Q);
149 } else if c == '[' {
150 let mut j = i + 1;
151 if j < n && (chars[j] == '!' || chars[j] == '^') {
152 j += 1;
153 }
154 if j < n && chars[j] == ']' {
155 j += 1;
156 }
157 while j < n && chars[j] != ']' {
158 j += 1;
159 }
160 if j >= n {
161 out.push(GlobTok::Lit('[')); } else {
163 let inner: Vec<char> = chars[i + 1..j].to_vec();
164 let (negated, body) = match inner.first() {
165 Some('!') | Some('^') => (true, &inner[1..]),
166 _ => (false, &inner[..]),
167 };
168 out.push(GlobTok::Class {
169 negated,
170 items: parse_class_items(body),
171 });
172 i = j + 1;
173 continue;
174 }
175 } else {
176 out.push(GlobTok::Lit(c));
177 }
178 i += 1;
179 }
180 out
181}
182
183fn parse_class_items(body: &[char]) -> Vec<ClassItem> {
185 let mut items: Vec<ClassItem> = Vec::new();
186 let mut k = 0usize;
187 let n = body.len();
188 while k < n {
189 let (atom, used, shorthand) = if body[k] == '\\' && k + 1 < n {
191 let e = body[k + 1];
192 let sh = match e {
193 'd' => Some(ClassItem::Digit),
194 'D' => Some(ClassItem::NonDigit),
195 'w' => Some(ClassItem::Word),
196 'W' => Some(ClassItem::NonWord),
197 's' => Some(ClassItem::Space),
198 'S' => Some(ClassItem::NonSpace),
199 _ => None,
200 };
201 (e, 2usize, sh)
202 } else {
203 (body[k], 1usize, None)
204 };
205 if let Some(sh) = shorthand {
206 items.push(sh);
207 k += used;
208 continue;
209 }
210 if k + used < n && body[k + used] == '-' && k + used + 1 < n {
212 let mut m = k + used + 1;
213 let hi = if body[m] == '\\' && m + 1 < n {
214 m += 1;
215 body[m]
216 } else {
217 body[m]
218 };
219 items.push(ClassItem::Range(atom, hi));
220 k = m + 1;
221 continue;
222 }
223 items.push(ClassItem::Ch(atom));
224 k += used;
225 }
226 items
227}
228
229fn class_matches(negated: bool, items: &[ClassItem], c: char) -> bool {
230 let hit = items.iter().any(|item| match item {
231 ClassItem::Ch(x) => c == *x,
232 ClassItem::Range(lo, hi) => (*lo..=*hi).contains(&c),
233 ClassItem::Digit => crate::pycompat::is_re_digit(c),
234 ClassItem::NonDigit => !crate::pycompat::is_re_digit(c),
235 ClassItem::Word => crate::pycompat::is_re_word(c),
236 ClassItem::NonWord => !crate::pycompat::is_re_word(c),
237 ClassItem::Space => py_re_space(c),
238 ClassItem::NonSpace => !py_re_space(c),
239 });
240 hit != negated
241}
242
243fn py_re_space(c: char) -> bool {
245 matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c' | '\u{1c}'..='\u{1f}' | '\u{85}')
246 || crate::pycompat::py_is_space(c)
247}
248
249fn glob_match_at(toks: &[GlobTok], s: &[char]) -> bool {
251 let Some(tok) = toks.first() else {
252 return s.is_empty();
253 };
254 let rest = &toks[1..];
255 match tok {
256 GlobTok::Lit(c) => s.first() == Some(c) && glob_match_at(rest, &s[1..]),
257 GlobTok::Q => s.first().is_some_and(|&c| c != '/') && glob_match_at(rest, &s[1..]),
258 GlobTok::Star => {
259 let limit = s.iter().take_while(|&&c| c != '/').count();
260 (0..=limit).any(|k| glob_match_at(rest, &s[k..]))
261 }
262 GlobTok::DotStar => {
263 let limit = s.iter().take_while(|&&c| c != '\n').count();
264 (0..=limit).any(|k| glob_match_at(rest, &s[k..]))
265 }
266 GlobTok::SegStar => {
267 if glob_match_at(rest, s) {
269 return true;
270 }
271 let mut i = 0usize;
273 while i < s.len() && s[i] != '/' {
274 i += 1;
275 }
276 i > 0 && i < s.len() && glob_match_at(toks, &s[i + 1..])
277 }
278 GlobTok::Class { negated, items } => s
279 .first()
280 .is_some_and(|&c| class_matches(*negated, items, c))
281 && glob_match_at(rest, &s[1..]),
282 }
283}
284
285fn entry_covers(entry: &str, query: &str) -> bool {
287 match classify_scope_entry(entry) {
288 "component" => false,
289 "glob" => {
290 let toks = compile_glob(py_strip(entry));
291 let q: Vec<char> = query.chars().collect();
292 glob_match_at(&toks, &q)
293 }
294 _ => match normalized_scope_path(entry) {
295 None => false,
296 Some(normalized) => {
297 query == normalized || query.starts_with(&format!("{normalized}/"))
298 }
299 },
300 }
301}
302
303fn normalize_query(path: &str, root: &Path) -> Option<String> {
305 let text = py_strip(path);
306 if text.is_empty() {
307 return None;
308 }
309 let (cand_root, mut cand_parts) = pure_posix_parts(text);
310 if cand_root.is_some() {
311 let root_posix = root.to_string_lossy().replace('\\', "/");
314 let (root_marker, root_parts) = pure_posix_parts(&root_posix);
315 if cand_root != root_marker
316 || cand_parts.len() < root_parts.len()
317 || cand_parts[..root_parts.len()] != root_parts[..]
318 {
319 return None; }
321 cand_parts = cand_parts[root_parts.len()..].to_vec();
322 }
323 let mut parts: Vec<String> = Vec::new();
324 for part in cand_parts {
325 if part == ".." {
326 return None;
327 }
328 parts.push(part);
329 }
330 if parts.is_empty() {
331 None
332 } else {
333 Some(parts.join("/"))
334 }
335}
336
337#[derive(Clone)]
343pub struct ScopeRow {
344 pub id: String,
345 pub title: String,
346 pub status: String,
347 pub path: String,
348 pub scope_entries: Vec<String>,
349}
350
351pub fn scope_rows_from_items(items: &[CorpusItem]) -> Vec<ScopeRow> {
353 let mut rows = Vec::new();
354 for item in items {
355 let Some(spec) = item.spec else { continue };
356 if spec.name != DECISION_TYPE || !is_live_decision(&item.artifact) {
357 continue;
358 }
359 let declared: Vec<String> = extract_relationships_full(&item.artifact, spec)
361 .into_iter()
362 .filter(|(section, _)| section == "applies_to")
363 .flat_map(|(_, refs)| refs)
364 .collect();
365 if declared.is_empty() {
366 continue;
367 }
368 rows.push(ScopeRow {
369 id: artifact_identifier(&item.artifact, Some(spec), &item.path),
370 title: item.artifact.product.title.clone().unwrap_or_default(),
371 status: artifact_status(&item.artifact),
372 path: item.path.clone(),
373 scope_entries: declared,
374 });
375 }
376 rows
377}
378
379pub struct GoverningDecision {
382 pub id: String,
383 pub title: String,
384 pub status: String,
385 pub path: String,
386 pub matching_entry: String,
387}
388
389fn governing_decisions(rows: &[ScopeRow], directory: &str, path: &str) -> Vec<GoverningDecision> {
391 let root = repository_root(directory);
392 let Some(query) = normalize_query(path, &root) else {
393 return Vec::new();
394 };
395 let mut matches: Vec<GoverningDecision> = Vec::new();
396 for row in rows {
397 for declared in &row.scope_entries {
398 if entry_covers(declared, &query) {
399 matches.push(GoverningDecision {
400 id: row.id.clone(),
401 title: row.title.clone(),
402 status: row.status.clone(),
403 path: row.path.clone(),
404 matching_entry: declared.clone(),
405 });
406 break;
407 }
408 }
409 }
410 matches.sort_by(|a, b| {
411 (py_casefold(&a.id), &a.path).cmp(&(py_casefold(&b.id), &b.path))
412 });
413 matches
414}
415
416pub struct ScopeLookupResult {
421 pub query: String,
422 pub in_repository: bool,
423 pub decisions: Vec<GoverningDecision>,
424}
425
426pub fn decisions_for_path(directory: &str, path: &str, recursive: bool) -> ScopeLookupResult {
432 let root = repository_root(directory);
433 match normalize_query(path, &root) {
434 None => ScopeLookupResult {
435 query: py_strip(path).to_string(),
436 in_repository: false,
437 decisions: Vec::new(),
438 },
439 Some(query) => {
440 let items = corpus_items(directory, recursive);
441 let rows = scope_rows_from_items(&items);
442 ScopeLookupResult {
443 query,
444 in_repository: true,
445 decisions: governing_decisions(&rows, directory, path),
446 }
447 }
448 }
449}
450
451pub fn scope_lookup_value(result: &ScopeLookupResult) -> Value {
454 let mut payload = Map::new();
455 payload.insert("schema_version".to_string(), json!("1"));
456 payload.insert("query".to_string(), json!(result.query));
457 payload.insert("in_repository".to_string(), json!(result.in_repository));
458 let decisions: Vec<Value> = result
459 .decisions
460 .iter()
461 .map(|d| {
462 let mut m = Map::new();
463 m.insert("id".to_string(), json!(d.id));
464 m.insert("title".to_string(), json!(d.title));
465 m.insert("status".to_string(), json!(d.status));
466 m.insert("path".to_string(), json!(d.path));
467 m.insert("matching_entry".to_string(), json!(d.matching_entry));
468 Value::Object(m)
469 })
470 .collect();
471 payload.insert("decisions".to_string(), Value::Array(decisions));
472 Value::Object(payload)
473}
474
475pub fn decisions_for_path_with_rows(
479 rows: &[ScopeRow],
480 directory: &str,
481 path: &str,
482) -> ScopeLookupResult {
483 let root = repository_root(directory);
484 match normalize_query(path, &root) {
485 None => ScopeLookupResult {
486 query: py_strip(path).to_string(),
487 in_repository: false,
488 decisions: Vec::new(),
489 },
490 Some(query) => ScopeLookupResult {
491 query,
492 in_repository: true,
493 decisions: governing_decisions(rows, directory, path),
494 },
495 }
496}
497
498pub fn find_decisions_path_payload(directory: &str, path: &str) -> Value {
505 scope_lookup_value(&decisions_for_path(directory, path, true))
506}
507
508fn successor_map(relationships: &[Relationship]) -> HashMap<String, Vec<String>> {
515 let mut by_target: HashMap<String, Vec<String>> = HashMap::new();
516 for rel in relationships {
517 if rel.relationship == SUPERSEDES {
518 if let Some(target) = &rel.resolved_path {
519 by_target
520 .entry(target.clone())
521 .or_default()
522 .push(rel.source_path.clone());
523 }
524 }
525 }
526 for sources in by_target.values_mut() {
527 sources.sort();
528 sources.dedup();
529 }
530 by_target
531}
532
533fn live_successors(
535 path: &str,
536 by_target: &HashMap<String, Vec<String>>,
537 is_retired: &dyn Fn(&str) -> bool,
538 visited: &mut std::collections::HashSet<String>,
539) -> Vec<String> {
540 let mut out: Vec<String> = Vec::new();
541 let Some(sources) = by_target.get(path) else {
542 return out;
543 };
544 for source in sources {
545 if visited.contains(source) {
546 continue;
547 }
548 visited.insert(source.clone());
549 if is_retired(source) {
550 out.extend(live_successors(source, by_target, is_retired, visited));
551 } else {
552 out.push(source.clone());
553 }
554 }
555 out
556}
557
558struct ItemBuilder {
560 id: String,
561 item_type: String,
562 title: Option<String>,
563 status: String,
564 path: String,
565 provenance: Map<String, Value>,
567}
568
569#[allow(clippy::too_many_arguments)]
570fn add_item(
571 items: &mut Vec<ItemBuilder>,
572 index_of: &mut HashMap<String, usize>,
573 path: &str,
574 channel: &str,
575 item_id: &str,
576 item_type: &str,
577 title: Option<&str>,
578 status: &str,
579 matching_entry: Option<&str>,
580 superseded: Option<&str>,
581 evidence: Option<Value>,
582) {
583 let idx = match index_of.get(path) {
584 Some(&i) => i,
585 None => {
586 let mut provenance = Map::new();
587 provenance.insert("channels".to_string(), json!([]));
588 items.push(ItemBuilder {
589 id: item_id.to_string(),
590 item_type: item_type.to_string(),
591 title: title.map(str::to_string),
592 status: status.to_string(),
593 path: path.to_string(),
594 provenance,
595 });
596 index_of.insert(path.to_string(), items.len() - 1);
597 items.len() - 1
598 }
599 };
600 let provenance = &mut items[idx].provenance;
601 {
602 let channels = provenance
603 .get_mut("channels")
604 .and_then(Value::as_array_mut)
605 .expect("channels array");
606 if !channels.iter().any(|c| c.as_str() == Some(channel)) {
607 channels.push(json!(channel));
608 }
609 }
610 if let Some(entry) = matching_entry {
611 if !provenance.contains_key("matching_entry") {
612 provenance.insert("matching_entry".to_string(), json!(entry));
613 }
614 }
615 if let Some(replaced_id) = superseded {
616 if !provenance.contains_key("superseded") {
617 provenance.insert("superseded".to_string(), json!([]));
618 }
619 let replaced = provenance
620 .get_mut("superseded")
621 .and_then(Value::as_array_mut)
622 .expect("superseded array");
623 if !replaced.iter().any(|r| r.as_str() == Some(replaced_id)) {
624 replaced.push(json!(replaced_id));
625 }
626 }
627 if let Some(ev) = evidence {
628 if !provenance.contains_key("evidence") {
629 provenance.insert("evidence".to_string(), ev);
630 }
631 }
632}
633
634pub fn retrieve_grounding(
638 directory: &str,
639 task: &str,
640 scope: Option<&str>,
641 top_k: i64,
642 budget: i64,
643 live_only: bool,
644) -> Value {
645 let top_k = top_k.max(1);
646 let corpus = corpus_items(directory, true);
647 let entries: Vec<IndexEntry> = index_from_items(&corpus);
648 let entry_by_path: HashMap<&str, &IndexEntry> =
649 entries.iter().map(|e| (e.path.as_str(), e)).collect();
650 let status_by_path: HashMap<&str, String> = corpus
653 .iter()
654 .map(|item| (item.path.as_str(), artifact_status(&item.artifact)))
655 .collect();
656 let status_of = |path: &str| -> String {
657 match status_by_path.get(path) {
658 Some(s) => s.clone(),
659 None => {
661 if Path::new(path).is_file() {
662 artifact_status(&crate::parse::parse_file(path))
663 } else {
664 String::new()
665 }
666 }
667 }
668 };
669 let keyword = search_index(&entries, task, None, &[]);
670 let relationships = relationships_from_corpus(&corpus);
671 let scope_rows = scope_rows_from_items(&corpus);
672 retrieve_grounding_from_parts(
673 directory,
674 task,
675 scope,
676 top_k,
677 budget,
678 live_only,
679 keyword,
680 &scope_rows,
681 &relationships,
682 |path| entry_by_path.get(path).map(|entry| (*entry).clone()),
683 status_of,
684 )
685}
686
687pub fn retrieve_grounding_from_derived(
691 directory: &str,
692 task: &str,
693 scope: Option<&str>,
694 top_k: i64,
695 budget: i64,
696 live_only: bool,
697 derived: &crate::derived::DerivedIndex,
698) -> Value {
699 let keyword = search_index(&derived.index_entries, task, None, &[]);
700 let entry_by_path: HashMap<&str, &IndexEntry> = derived
701 .index_entries
702 .iter()
703 .map(|entry| (entry.path.as_str(), entry))
704 .collect();
705 let status_cache = std::cell::RefCell::new(HashMap::<String, String>::new());
706 let status_of = |path: &str| {
707 entry_by_path
708 .get(path)
709 .map(|entry| status_from_entry(entry))
710 .unwrap_or_else(|| cached_status(&status_cache, path))
711 };
712 retrieve_grounding_from_parts(
713 directory,
714 task,
715 scope,
716 top_k,
717 budget,
718 live_only,
719 keyword,
720 &derived.scope_rows,
721 &derived.relationships,
722 |path| entry_by_path.get(path).map(|entry| (*entry).clone()),
723 status_of,
724 )
725}
726
727pub fn retrieve_grounding_from_store(
731 directory: &str,
732 task: &str,
733 scope: Option<&str>,
734 top_k: i64,
735 budget: i64,
736 live_only: bool,
737 reader: &crate::index_store::MmapIndexReader,
738) -> Value {
739 let search_started = crate::timing::start();
740 let keyword = crate::read_model::store_search(reader, task, None, &[], false);
741 crate::timing::emit_since(
742 "grounding.search",
743 search_started,
744 &[("matches", keyword.matches.len() as u64)],
745 );
746 let decode_started = crate::timing::start();
747 let scope_rows = if scope.is_some_and(|value| !value.is_empty()) {
748 reader.scope_rows().unwrap_or_default()
749 } else {
750 Vec::new()
751 };
752 let relationships = if live_only {
753 reader.relationships().unwrap_or_default()
754 } else {
755 Vec::new()
756 };
757 crate::timing::emit_since(
758 "grounding.projections",
759 decode_started,
760 &[
761 ("scope_rows", scope_rows.len() as u64),
762 ("relationships", relationships.len() as u64),
763 ],
764 );
765 let status_cache = std::cell::RefCell::new(HashMap::<String, String>::new());
766 let status_of = |path: &str| {
767 reader
768 .docid_for_path(path)
769 .ok()
770 .flatten()
771 .and_then(|docid| reader.entry_status(docid).ok())
772 .unwrap_or_else(|| cached_status(&status_cache, path))
773 };
774 retrieve_grounding_from_parts(
775 directory,
776 task,
777 scope,
778 top_k,
779 budget,
780 live_only,
781 keyword,
782 &scope_rows,
783 &relationships,
784 |path| {
785 reader
786 .docid_for_path(path)
787 .ok()
788 .flatten()
789 .and_then(|docid| reader.identity_entry(docid).ok())
790 },
791 status_of,
792 )
793}
794
795fn cached_status(
796 cache: &std::cell::RefCell<HashMap<String, String>>,
797 path: &str,
798) -> String {
799 if let Some(status) = cache.borrow().get(path) {
800 return status.clone();
801 }
802 let status = if Path::new(path).is_file() {
803 artifact_status(&crate::parse::parse_file(path))
804 } else {
805 String::new()
806 };
807 cache.borrow_mut().insert(path.to_string(), status.clone());
808 status
809}
810
811fn status_from_entry(entry: &IndexEntry) -> String {
812 entry
813 .search_sections
814 .iter()
815 .find(|section| py_casefold(py_strip(§ion.heading)) == "status")
816 .and_then(|section| {
817 section
818 .lines
819 .iter()
820 .map(|line| py_strip(line))
821 .find(|line| !line.is_empty())
822 })
823 .unwrap_or("")
824 .to_string()
825}
826
827#[allow(clippy::too_many_arguments)]
828fn retrieve_grounding_from_parts<EntryForPath, StatusOf>(
829 directory: &str,
830 task: &str,
831 scope: Option<&str>,
832 top_k: i64,
833 budget: i64,
834 live_only: bool,
835 keyword: SearchResult,
836 scope_rows: &[ScopeRow],
837 relationships: &[Relationship],
838 entry_for_path: EntryForPath,
839 status_of: StatusOf,
840) -> Value
841where
842 EntryForPath: Fn(&str) -> Option<IndexEntry>,
843 StatusOf: Fn(&str) -> String,
844{
845 let top_k = top_k.max(1);
846 let is_retired = |path: &str| -> bool {
847 let artifact_type = entry_for_path(path)
848 .map(|entry| entry.artifact_type)
849 .unwrap_or_else(|| DECISION_TYPE.to_string());
850 is_retired_status(&artifact_type, &status_of(path))
851 };
852
853 let mut items: Vec<ItemBuilder> = Vec::new();
854 let mut index_of: HashMap<String, usize> = HashMap::new();
855
856 let scope = scope.filter(|s| !s.is_empty()); if let Some(scope_path) = scope {
860 for governing in governing_decisions(scope_rows, directory, scope_path) {
861 add_item(
862 &mut items,
863 &mut index_of,
864 &governing.path,
865 CHANNEL_SCOPE,
866 &governing.id,
867 DECISION_TYPE,
868 if governing.title.is_empty() {
869 None
870 } else {
871 Some(&governing.title)
872 },
873 &governing.status,
874 Some(&governing.matching_entry),
875 None,
876 None,
877 );
878 }
879 }
880
881 let by_target = if live_only {
883 successor_map(relationships)
884 } else {
885 HashMap::new()
886 };
887 for m in &keyword.matches {
888 if live_only && is_retired(&m.path) {
889 let mut visited: std::collections::HashSet<String> =
890 std::collections::HashSet::new();
891 visited.insert(m.path.clone());
892 for successor_path in live_successors(&m.path, &by_target, &is_retired, &mut visited)
893 {
894 let Some(successor) = entry_for_path(&successor_path) else {
895 continue;
896 };
897 add_item(
898 &mut items,
899 &mut index_of,
900 &successor_path,
901 CHANNEL_SUPERSEDES,
902 &successor.id,
903 &successor.artifact_type,
904 successor.title.as_deref(),
905 &status_of(&successor_path),
906 None,
907 Some(&m.id),
908 None,
909 );
910 }
911 continue;
912 }
913 add_item(
914 &mut items,
915 &mut index_of,
916 &m.path,
917 CHANNEL_KEYWORD,
918 &m.id,
919 &m.artifact_type,
920 m.title.as_deref(),
921 &status_of(&m.path),
922 None,
923 None,
924 m.evidence.as_ref().map(crate::output::evidence_value),
925 );
926 }
927
928 let selected: Vec<ItemBuilder> = {
929 let keep = (top_k.max(0) as usize).min(items.len());
930 items.truncate(keep);
931 items
932 };
933 let share = if selected.is_empty() {
936 0
937 } else {
938 budget.div_euclid((top_k.min(selected.len() as i64)).max(1))
939 };
940 let mut shaped: Vec<Value> = Vec::new();
941 for item in selected {
942 let content = read_text_universal(&item.path).unwrap_or_default();
943 let mut obj = Map::new();
944 obj.insert("id".to_string(), json!(item.id));
945 obj.insert("type".to_string(), json!(item.item_type));
946 obj.insert(
947 "title".to_string(),
948 item.title.map(|t| json!(t)).unwrap_or(Value::Null),
949 );
950 obj.insert("status".to_string(), json!(item.status));
951 obj.insert("path".to_string(), json!(item.path));
952 obj.insert("excerpt".to_string(), json!(py_slice_to(&content, share)));
953 obj.insert("provenance".to_string(), Value::Object(item.provenance));
954 shaped.push(Value::Object(obj));
955 }
956
957 let mut payload = Map::new();
958 payload.insert("schema_version".to_string(), json!("1"));
959 payload.insert("task".to_string(), json!(task));
960 if let Some(scope_path) = scope {
961 payload.insert("scope".to_string(), json!(scope_path));
962 }
963 payload.insert("live_only".to_string(), json!(live_only));
964 payload.insert("items".to_string(), Value::Array(shaped));
965 Value::Object(payload)
966}