Skip to main content

eidos_kernel/workflow/
draft.rs

1use std::collections::BTreeSet;
2
3use super::{RetryPolicy, TaskContextItem, TaskDag, TaskNode, TaskOperator, TaskRisk, TaskState};
4
5const MAX_DIRECT_CONTEXT_INSPECTIONS: usize = 24;
6
7/// Draft a conservative non-executing decomposition from a task and its focused context.
8///
9/// The planner emits a context gather step, one inspection step per receipt-backed context item,
10/// then bounded change/check/review/approval steps. It gives agents a stable contract for planning
11/// without granting an executor.
12pub fn draft_task_dag(task: &str, context: &[TaskContextItem]) -> TaskDag {
13    let id = format!("task.{}", slug(task));
14    let mut receipts = context
15        .iter()
16        .filter_map(|item| (!item.source.trim().is_empty()).then_some(item.source.clone()))
17        .collect::<Vec<_>>();
18    receipts.sort();
19    receipts.dedup();
20
21    let has_code = context.iter().any(|item| {
22        matches!(
23            item.kind.as_str(),
24            "function" | "type" | "trait" | "module" | "code"
25        )
26    });
27    let has_docs = context
28        .iter()
29        .any(|item| matches!(item.kind.as_str(), "doc" | "section" | "skill" | "agent"));
30
31    let mut nodes = vec![TaskNode {
32        id: "context".to_string(),
33        title: "Gather receipt-backed context".to_string(),
34        operator: TaskOperator::ReadContext,
35        state: TaskState::Pending,
36        depends_on: Vec::new(),
37        inputs: vec![task.to_string()],
38        outputs: vec!["context_pack".to_string()],
39        required_checks: vec!["receipt_coverage".to_string()],
40        tools: vec!["focus".to_string()],
41        retry: RetryPolicy::default(),
42        risk: TaskRisk::Low,
43        receipts: receipts.clone(),
44    }];
45
46    let mut inspect_ids = Vec::new();
47    let mut used_ids = BTreeSet::from(["context".to_string()]);
48    for item in context.iter().take(MAX_DIRECT_CONTEXT_INSPECTIONS) {
49        let inspect_id = unique_node_id(&format!("inspect-{}", slug(&item.id)), &mut used_ids);
50        inspect_ids.push(inspect_id.clone());
51        nodes.push(TaskNode {
52            id: inspect_id,
53            title: format!("Inspect {}", item.title),
54            operator: TaskOperator::ReadContext,
55            state: TaskState::Pending,
56            depends_on: vec!["context".to_string()],
57            inputs: vec![item.id.clone()],
58            outputs: vec![format!("inspected:{}", item.id)],
59            required_checks: vec!["receipt_read".to_string()],
60            tools: vec!["read".to_string(), "get_node".to_string()],
61            retry: RetryPolicy::default(),
62            risk: TaskRisk::Low,
63            receipts: if item.source.trim().is_empty() {
64                Vec::new()
65            } else {
66                vec![item.source.clone()]
67            },
68        });
69    }
70    if context.len() > MAX_DIRECT_CONTEXT_INSPECTIONS {
71        let remaining = &context[MAX_DIRECT_CONTEXT_INSPECTIONS..];
72        let inspect_id = unique_node_id("inspect-remaining-context", &mut used_ids);
73        inspect_ids.push(inspect_id.clone());
74        nodes.push(TaskNode {
75            id: inspect_id,
76            title: format!("Inspect {} remaining context items", remaining.len()),
77            operator: TaskOperator::ReadContext,
78            state: TaskState::Pending,
79            depends_on: vec!["context".to_string()],
80            inputs: remaining.iter().map(|item| item.id.clone()).collect(),
81            outputs: vec!["inspected:remaining_context".to_string()],
82            required_checks: vec!["receipt_read".to_string()],
83            tools: vec!["read".to_string(), "get_node".to_string()],
84            retry: RetryPolicy::default(),
85            risk: TaskRisk::Low,
86            receipts: remaining
87                .iter()
88                .filter_map(|item| (!item.source.trim().is_empty()).then_some(item.source.clone()))
89                .collect(),
90        });
91    }
92
93    let decompose_deps = if inspect_ids.is_empty() {
94        vec!["context".to_string()]
95    } else {
96        inspect_ids
97    };
98    nodes.push(TaskNode {
99        id: "decompose".to_string(),
100        title: "Decompose task into bounded work".to_string(),
101        operator: TaskOperator::Decompose,
102        state: TaskState::Pending,
103        depends_on: decompose_deps,
104        inputs: vec!["context_pack".to_string()],
105        outputs: vec!["work_items".to_string()],
106        required_checks: vec!["dag_is_acyclic".to_string()],
107        tools: vec!["brief_task".to_string()],
108        retry: RetryPolicy::default(),
109        risk: TaskRisk::Low,
110        receipts: Vec::new(),
111    });
112
113    let change_operator = if has_code {
114        TaskOperator::Edit
115    } else {
116        TaskOperator::ProposeDoc
117    };
118    nodes.push(TaskNode {
119        id: "change".to_string(),
120        title: if has_code {
121            "Prepare code change proposal".to_string()
122        } else if has_docs {
123            "Prepare documentation proposal".to_string()
124        } else {
125            "Prepare knowledge proposal".to_string()
126        },
127        operator: change_operator,
128        state: TaskState::Pending,
129        depends_on: vec!["decompose".to_string()],
130        inputs: vec!["work_items".to_string()],
131        outputs: vec!["change_set".to_string()],
132        required_checks: if has_code {
133            vec!["compile_or_typecheck".to_string()]
134        } else {
135            vec!["sigil_validation".to_string()]
136        },
137        tools: if has_code {
138            vec!["edit".to_string()]
139        } else {
140            vec!["propose".to_string(), "sigil-diff".to_string()]
141        },
142        retry: RetryPolicy {
143            max_attempts: 2,
144            backoff_ms: 0,
145        },
146        risk: if has_code {
147            TaskRisk::High
148        } else {
149            TaskRisk::Medium
150        },
151        receipts,
152    });
153
154    nodes.push(TaskNode {
155        id: "verify".to_string(),
156        title: "Verify proposed work".to_string(),
157        operator: TaskOperator::Check,
158        state: TaskState::Pending,
159        depends_on: vec!["change".to_string()],
160        inputs: vec!["change_set".to_string()],
161        outputs: vec!["verification_report".to_string()],
162        required_checks: if has_code {
163            vec!["tests_pass".to_string(), "lint_passes".to_string()]
164        } else {
165            vec!["proposal_diff_reviewed".to_string()]
166        },
167        tools: if has_code {
168            vec!["test".to_string(), "lint".to_string()]
169        } else {
170            vec!["sigil-diff".to_string()]
171        },
172        retry: RetryPolicy {
173            max_attempts: 2,
174            backoff_ms: 250,
175        },
176        risk: TaskRisk::Medium,
177        receipts: Vec::new(),
178    });
179
180    nodes.push(TaskNode {
181        id: "review".to_string(),
182        title: "Review receipts and risks".to_string(),
183        operator: TaskOperator::Review,
184        state: TaskState::Pending,
185        depends_on: vec!["verify".to_string()],
186        inputs: vec!["verification_report".to_string()],
187        outputs: vec!["review_notes".to_string()],
188        required_checks: vec!["receipts_match_claims".to_string()],
189        tools: vec!["review".to_string()],
190        retry: RetryPolicy::default(),
191        risk: TaskRisk::Low,
192        receipts: Vec::new(),
193    });
194
195    nodes.push(TaskNode {
196        id: "approval".to_string(),
197        title: "Human approval gate".to_string(),
198        operator: TaskOperator::HumanApproval,
199        state: TaskState::Pending,
200        depends_on: vec!["review".to_string()],
201        inputs: vec!["review_notes".to_string()],
202        outputs: vec!["approved_or_rejected".to_string()],
203        required_checks: vec!["human_disposes".to_string()],
204        tools: vec!["sigil-accept".to_string()],
205        retry: RetryPolicy::default(),
206        risk: TaskRisk::High,
207        receipts: Vec::new(),
208    });
209
210    TaskDag {
211        id,
212        title: task.trim().to_string(),
213        nodes,
214    }
215}
216
217fn slug(value: &str) -> String {
218    let mut out = String::new();
219    let mut last_dash = false;
220    for ch in value.chars().flat_map(char::to_lowercase) {
221        if ch.is_ascii_alphanumeric() {
222            out.push(ch);
223            last_dash = false;
224        } else if !last_dash && !out.is_empty() {
225            out.push('-');
226            last_dash = true;
227        }
228    }
229    while out.ends_with('-') {
230        out.pop();
231    }
232    if out.is_empty() {
233        "untitled".to_string()
234    } else {
235        out
236    }
237}
238
239fn unique_node_id(base: &str, used: &mut BTreeSet<String>) -> String {
240    let mut candidate = base.to_string();
241    let mut suffix = 2usize;
242    while !used.insert(candidate.clone()) {
243        candidate = format!("{base}-{suffix}");
244        suffix += 1;
245    }
246    candidate
247}