oxios_kernel/token_maxing/planner.rs
1//! WorkPlanner — three-source task synthesis (RFC-031 §7).
2//!
3//! Selects the next task for the TokenMaxer from three prioritized sources,
4//! each filtered to non-destructive, bounded work (maxing runs unattended — no
5//! `rm`, no deploys, no outbound network beyond read):
6//!
7//! - **Source A — autonomous skills**: skills with frontmatter `autonomous:
8//! true`. This is the "자주 실행되던 스킬" axis.
9//! - **Source B — registered projects/mounts**: a bounded, read-mostly review
10//! task synthesized from each project's paths.
11//! - **Source C — recurring patterns**: stubbed (lowest priority; avoids
12//! inventing work).
13
14use std::collections::HashSet;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use super::session::TaskSource;
19
20/// A unit of work the TokenMaxer can dispatch on one provider.
21#[derive(Debug, Clone)]
22pub struct PlannedTask {
23 /// Which source produced this task.
24 pub source: TaskSource,
25 /// Human label (skill/project name) — for the status panel + report.
26 pub source_name: String,
27 /// The synthesized goal prompt handed to the agent.
28 pub goal: String,
29 /// Workspace paths to sandbox the agent to (project tasks only).
30 pub mount_paths: Vec<PathBuf>,
31}
32
33/// Selects the next task for a maxing run.
34pub struct WorkPlanner {
35 skills: Arc<crate::skill::SkillManager>,
36 projects: Option<Arc<crate::project::ProjectManager>>,
37 /// Mount manager — resolves a project's `mount_ids` into filesystem
38 /// paths (RFC-025: paths live on Mounts, not Projects).
39 mounts: Option<Arc<crate::mount::MountManager>>,
40}
41
42impl WorkPlanner {
43 pub fn new(
44 skills: Arc<crate::skill::SkillManager>,
45 projects: Option<Arc<crate::project::ProjectManager>>,
46 mounts: Option<Arc<crate::mount::MountManager>>,
47 ) -> Self {
48 Self {
49 skills,
50 projects,
51 mounts,
52 }
53 }
54
55 /// Pick the next task not already done this session (`done_goals`).
56 ///
57 /// Returns `None` when no eligible work remains — the maxer then terminates
58 /// the window early ("stopped: nothing to do") rather than fabricating work.
59 pub async fn next_task(&self, done_goals: &HashSet<String>) -> Option<PlannedTask> {
60 // Source A — autonomous-eligible skills.
61 for entry in self.skills.list_skills().await {
62 let autonomous = entry
63 .metadata
64 .as_ref()
65 .map(|m| m.autonomous)
66 .unwrap_or(false);
67 if !autonomous {
68 continue;
69 }
70 let goal = format!(
71 "[Skill: {}] {}. Perform this skill's routine work autonomously and \
72 summarize the outcome. Do not make destructive changes or run deploys.",
73 entry.skill.name, entry.skill.description
74 );
75 if done_goals.contains(&goal) {
76 continue;
77 }
78 return Some(PlannedTask {
79 source: TaskSource::Skill,
80 source_name: entry.skill.name.clone(),
81 goal,
82 mount_paths: Vec::new(),
83 });
84 }
85
86 // Source B — registered projects (read-mostly review).
87 if let Some(pm) = &self.projects {
88 for proj in pm.list_projects() {
89 // RFC-025: a project's filesystem scope is its referenced
90 // Mounts. Skip projects with no Mounts (non-code, or
91 // pre-migration) — there is nothing on disk to review.
92 if proj.mount_ids.is_empty() {
93 continue;
94 }
95 let mount_paths = self.resolve_mount_paths(&proj.mount_ids);
96 if mount_paths.is_empty() {
97 continue;
98 }
99 let goal = format!(
100 "[Project: {}] Review the recent state of this project: summarize open \
101 work, recent changes, and any TODOs/FIXMEs. Read-only — do not modify \
102 files or run deploys.",
103 proj.name
104 );
105 if done_goals.contains(&goal) {
106 continue;
107 }
108 return Some(PlannedTask {
109 source: TaskSource::Project,
110 source_name: proj.name.clone(),
111 goal,
112 mount_paths,
113 });
114 }
115 }
116
117 // Source C — recurring patterns: intentionally stubbed (RFC-031 §7).
118 None
119 }
120
121 /// Resolve a project's `mount_ids` into deduplicated filesystem paths via
122 /// the Mount manager (RFC-025: paths live on Mounts, not Projects).
123 fn resolve_mount_paths(&self, mount_ids: &[crate::mount::MountId]) -> Vec<PathBuf> {
124 let Some(mm) = &self.mounts else {
125 return Vec::new();
126 };
127 let mut paths = Vec::new();
128 for mount in mm.get_mounts_ordered(mount_ids) {
129 for p in &mount.paths {
130 if !paths.contains(p) {
131 paths.push(p.clone());
132 }
133 }
134 }
135 paths
136 }
137}