Skip to main content

scc_engine/
task.rs

1//! Engine task artifact: THE one complete task derivation.
2//!
3//! Moved verbatim from `scc-cli` commands.rs: `build_task_context` is the
4//! transport-parity root — CLI text, CLI --json, MCP, HTTP, Hermes, SDKs,
5//! and hooks all derive from this ONE builder; outputs differ in
6//! serialization only, never in semantic content. The scorer is resolved
7//! ONCE and feeds BOTH the pack rankers and the delta Surface request.
8
9use scc_api::TaskContextRequest;
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::Path;
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14// trace:exempt reason=internal-detail
15pub struct TaskContextArtifact {
16    pub pack: scc_context::ContextPack,
17    pub delta: String,
18    #[serde(default)]
19    pub delta_ids: Vec<String>,
20    #[serde(default)]
21    pub token_count: usize,
22}
23
24// trace:exempt reason=internal-detail
25pub(crate) fn surface_task_files(
26    ctx: &scc_context::ContextCompiler,
27    goal: &str,
28    limit: usize,
29    tokens: usize,
30    semantic: Option<&dyn scc_context::rank::SemanticScorer>,
31) -> Vec<String> {
32    let budget = tokens.max(1);
33    let request = scc_context::surface::SurfaceRequest {
34        mode: scc_context::surface::SurfaceMode::Task { goal, visible: None },
35        budget,
36        explain: false,
37        policy: scc_context::surface::SurfacePolicy::defaults(budget),
38        semantic,
39    };
40    let result = scc_context::surface::build_surface(ctx, request);
41    let by_id: BTreeMap<&str, &scc_core::SurfaceEntry> = result
42        .rendered_entries
43        .iter()
44        .map(|e| (e.id.as_str(), e))
45        .collect();
46    let mut files: Vec<String> = Vec::new();
47    let mut seen: BTreeSet<String> = BTreeSet::new();
48    for id in &result.rendered_ids {
49        let path = by_id.get(id.as_str()).map(|e| e.path.as_str()).unwrap_or("");
50        if path.is_empty() || !seen.insert(path.to_string()) {
51            continue;
52        }
53        files.push(path.to_string());
54        if files.len() >= limit {
55            break;
56        }
57    }
58    files
59}
60
61// trace:v1 id=impl.crates-scc-engine-src-task.truncate-to work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
62pub(crate) fn truncate_to(content: &str, cap: usize) -> String {
63    if scc_core::estimate_tokens(content) <= cap {
64        return content.to_string();
65    }
66    const FOOTER: &str = "\n\n\u{2026} [task hard cap: content truncated to fit budget]\n";
67    let footer_tokens = scc_core::estimate_tokens(FOOTER);
68    if footer_tokens > cap {
69        return String::new();
70    }
71    let target = cap.saturating_sub(footer_tokens);
72    let mut lo = 0usize;
73    let mut hi = content.len();
74    while lo < hi {
75        let mid = (lo + hi).div_ceil(2);
76        let prefix = &content[..content.floor_char_boundary(mid)];
77        if scc_core::estimate_tokens(prefix) <= target {
78            lo = mid;
79        } else {
80            hi = mid.saturating_sub(1);
81        }
82    }
83    let mut cut = content.floor_char_boundary(lo);
84    let min_chars = content[..cut].chars().count() / 2;
85    if let Some(nl) = content[..cut].rfind('\n') {
86        let prefix_chars = content[..nl].chars().count();
87        if prefix_chars >= min_chars {
88            cut = nl;
89        }
90    }
91    if let Some(rest) = content.get(cut..) {
92        if let Some(nl) = rest.find('\n') {
93            let extended = cut + nl;
94            let mut candidate = content[..extended].to_string();
95            candidate.push_str(FOOTER);
96            if scc_core::estimate_tokens(&candidate) <= cap {
97                cut = extended;
98            }
99        }
100    }
101    let mut out = content[..cut].to_string();
102    out.push_str(FOOTER);
103    while scc_core::estimate_tokens(&out) > cap && cut > 0 {
104        cut = content.floor_char_boundary(cut.saturating_sub(1));
105        if let Some(nl) = content[..cut].rfind('\n') {
106            cut = nl;
107        }
108        out = content[..cut].to_string();
109        out.push_str(FOOTER);
110        if cut == 0 {
111            break;
112        }
113    }
114    out
115}
116
117// trace:exempt reason=internal-detail
118pub struct EnrichedPack {
119    pub pack: scc_context::ContextPack,
120    pub base: String,
121    pub beads: String,
122    pub hindsight: String,
123}
124
125// trace:v1 id=impl.crates-scc-engine-src-task.enriched-pack work=WORK-SI-MMMJA4G6 implements=PLAN-SI-SYKFPBEC
126impl EnrichedPack {
127    // trace:exempt reason=internal-detail
128    pub fn tokens_including(&self, hindsight: bool, beads: bool) -> usize {
129        scc_core::estimate_tokens(&self.base)
130            + if beads { scc_core::estimate_tokens(&self.beads) } else { 0 }
131            + if hindsight { scc_core::estimate_tokens(&self.hindsight) } else { 0 }
132    }
133
134    // trace:exempt reason=internal-detail
135    pub fn assemble(&mut self, hindsight: bool, beads: bool) {
136        self.pack.content = String::new();
137        self.pack.content.push_str(&self.base);
138        if beads {
139            self.pack.content.push_str(&self.beads);
140        }
141        if hindsight {
142            self.pack.content.push_str(&self.hindsight);
143        }
144        self.pack.tokens = scc_core::estimate_tokens(&self.pack.content);
145    }
146}
147
148#[allow(clippy::too_many_arguments)]
149// trace:exempt reason=internal-detail
150pub fn enrich_task_pack(
151    engine: &crate::workspace::Engine,
152    config: &scc_indexer::Config,
153    root: &Path,
154    goal: &str,
155    files: &[String],
156    symbols: &[String],
157    budget: Option<usize>,
158    hook: bool,
159    scorer: Option<&dyn scc_context::rank::SemanticScorer>,
160    reranker: Option<&dyn scc_context::rank::Reranker>,
161) -> EnrichedPack {
162    let mut pack = engine.ctx().task_context_with_rankers(
163        goal,
164        files,
165        symbols,
166        if hook { Some(budget.unwrap_or(1500).min(1500)) } else { budget },
167        scorer,
168        reranker,
169    );
170    let mut beads = String::new();
171    let beads_active = scc_indexer::adapters::beads::active_beads(root, 5);
172    if !beads_active.is_empty() {
173        beads.push_str("\n# ACTIVE TASK STATE (from .beads/issues.jsonl \u{2014} task state, not system facts)\n");
174        for t in beads_active {
175            beads.push_str("- ");
176            beads.push_str(&t);
177            beads.push('\n');
178        }
179    }
180    let mut hindsight = String::new();
181    if config.integrations.hindsight {
182        let lessons = scc_indexer::adapters::hindsight::lessons(engine.store, 5);
183        if !lessons.is_empty() {
184            hindsight.push_str("\n# HINDSIGHT LESSONS (memory, below System IR authority \u{2014} not verified facts)\n");
185            for (content, tags) in lessons {
186                let tag_str = if tags.is_empty() { String::new() } else { format!(" [{}]", tags.join(", ")) };
187                hindsight.push_str(&format!("- {content}{tag_str}\n"));
188            }
189        }
190    }
191    let sections = crate::plugins::context_sections(root, config, goal, files, symbols);
192    let base = pack.content.clone();
193    pack.content.push_str(&sections);
194    pack.content.push_str(&beads);
195    pack.content.push_str(&hindsight);
196    pack.tokens = scc_core::estimate_tokens(&pack.content);
197    // Sections are pack content (same authority line as beads/hindsight):
198    // fold into base so assemble() keeps them under budget accounting.
199    let mut base_with_sections = base;
200    base_with_sections.push_str(&sections);
201    EnrichedPack { pack, base: base_with_sections, beads, hindsight }
202}
203
204/// THE one complete task artifact builder. Transports pass the opened
205/// engine + resolved scorer/reranker; the engine derives pack + delta +
206/// ledger + cap enforcement identically for every caller.
207#[allow(clippy::too_many_arguments)]
208// trace:exempt reason=internal-detail
209pub fn build_task_context(
210    engine: &crate::workspace::Engine,
211    config: &scc_indexer::Config,
212    root: &Path,
213    req: &TaskContextRequest,
214    scorer: Option<&dyn scc_context::rank::SemanticScorer>,
215    reranker: Option<&dyn scc_context::rank::Reranker>,
216) -> crate::Result<TaskContextArtifact> {
217    let mut ep = enrich_task_pack(engine, config, root, &req.goal, &req.files, &req.symbols, req.budget, req.hook, scorer, reranker);
218    let hard_cap: Option<usize> = if req.hook { Some(req.budget.unwrap_or(1500).min(1500)) } else { req.budget };
219    let ctx = engine.ctx();
220    let ledger_store = scc_context::context_ledger::ContextLedgerStore::new(engine.store);
221    let visible = ledger_store.load();
222    let delta_budget = match hard_cap {
223        Some(c) => c.saturating_sub(ep.pack.tokens),
224        None => scc_core::ContextBudget::default().task_delta,
225    };
226    let (mut delta, mut delta_ids) = scc_context::startup::task_delta_with_ids(&ctx, &req.goal, &visible, delta_budget, scorer);
227    let mut include_hindsight = true;
228    let mut include_beads = true;
229    let mut dropped: Vec<&str> = Vec::new();
230    if let Some(cap) = hard_cap {
231        let full = ep.tokens_including(true, true) + scc_core::estimate_tokens(&delta);
232        if full > cap && include_hindsight && !ep.hindsight.is_empty() {
233            include_hindsight = false;
234            dropped.push("hindsight");
235        }
236        let no_h = ep.tokens_including(include_hindsight, true) + scc_core::estimate_tokens(&delta);
237        if no_h > cap && include_beads && !ep.beads.is_empty() {
238            include_beads = false;
239            dropped.push("beads");
240        }
241        let no_e = ep.tokens_including(include_hindsight, include_beads) + scc_core::estimate_tokens(&delta);
242        if no_e > cap && !delta.is_empty() {
243            delta = String::new();
244            delta_ids.clear();
245            dropped.push("surface-delta");
246        }
247    }
248    ep.assemble(include_hindsight, include_beads);
249    if let Some(cap) = hard_cap {
250        let total = scc_core::estimate_tokens(&ep.pack.content) + scc_core::estimate_tokens(&delta);
251        if total > cap {
252            ep.pack.content = truncate_to(&ep.pack.content, cap);
253            ep.pack.tokens = scc_core::estimate_tokens(&ep.pack.content);
254            ep.pack.hard_truncated = true;
255            dropped.push("task-pack");
256        }
257    }
258    if !delta_ids.is_empty() && req.record_visibility {
259        let mut led = visible;
260        crate::context::record_visible_ids(&mut led, &ctx, &delta_ids);
261        ledger_store.save(&led);
262    }
263    let token_count = scc_core::estimate_tokens(&ep.pack.content) + scc_core::estimate_tokens(&delta);
264    let mut artifact = TaskContextArtifact { pack: ep.pack, delta, delta_ids, token_count };
265    if let Some(cap) = hard_cap {
266        assert!(artifact.token_count <= cap, "task artifact {token_count} exceeded hard cap {cap}");
267    }
268    if !dropped.is_empty() {
269        artifact.pack.warnings.push(format!("task cap enforced: dropped [{}]", dropped.join(", ")));
270    }
271    Ok(artifact)
272}
273
274/// Pack-only builder: NO delta, NO ledger mutation. Compress and other
275/// pack-only callers MUST use this — never `build_task_context`.
276// trace:exempt reason=internal-detail
277pub fn build_enriched_task_pack(
278    engine: &crate::workspace::Engine,
279    config: &scc_indexer::Config,
280    root: &Path,
281    req: &TaskContextRequest,
282    scorer: Option<&dyn scc_context::rank::SemanticScorer>,
283    reranker: Option<&dyn scc_context::rank::Reranker>,
284) -> crate::Result<scc_context::ContextPack> {
285    Ok(enrich_task_pack(engine, config, root, &req.goal, &req.files, &req.symbols, req.budget, req.hook, scorer, reranker).pack)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    // trace:v1 id=test.scc-engine-task.truncate-to-hard-cap work=WORK-task-context-transport-parity verifies=REQ-complete-task-context-identical-across-transports,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient exercises=impl.crates-scc-engine-src-task.truncate-to
294    fn truncate_to_never_exceeds_hard_cap_when_next_newline_is_far_past() {
295        // Binary search finds a fitting prefix, then the old implementation
296        // extended FORWARD to the next newline (`cut += nl`), which blew
297        // past the cap when that newline was far away. A true hard cap
298        // must back off (or re-check after extending).
299        let mut body = String::from("HEADER\n");
300        body.push_str(&"x".repeat(400)); // ~100 tokens before the next newline
301        body.push('\n');
302        body.push_str("TAIL\n");
303        let cap = 40; // far below the long line
304        let out = truncate_to(&body, cap);
305        let tokens = scc_core::estimate_tokens(&out);
306        assert!(
307            tokens <= cap,
308            "truncate_to must honor the hard cap: {tokens} > {cap}\n{out:?}"
309        );
310        assert!(
311            out.contains("task hard cap"),
312            "footer must be present: {out}"
313        );
314        assert!(
315            !out.contains(&"x".repeat(400)),
316            "must not include the line that sits far past the cap"
317        );
318    }
319
320    #[test]
321    // trace:v1 id=test.scc-engine-task.truncate-to-fits-already work=WORK-task-context-transport-parity verifies=REQ-complete-task-context-identical-across-transports
322    fn truncate_to_returns_original_when_under_cap() {
323        let content = "short\n";
324        assert_eq!(truncate_to(content, 1000), content);
325    }
326
327    #[test]
328    // trace:v1 id=test.scc-engine-task.truncate-to-footer-exceeds-cap work=WORK-task-context-transport-parity verifies=REQ-complete-task-context-identical-across-transports,REQ-implement-p0-omp-integration-correctness-and-writable-benchmark-scient exercises=impl.crates-scc-engine-src-task.truncate-to
329    fn truncate_to_returns_empty_when_footer_exceeds_cap() {
330        let content = "HEADER\nbody that does not fit\n";
331        let out = truncate_to(content, 1);
332        assert!(
333            out.is_empty(),
334            "footer larger than the cap must not be returned: {out:?} tokens={}",
335            scc_core::estimate_tokens(&out)
336        );
337    }
338}