Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions crates/pu-cli/assets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,29 @@ pu schedule list # list all schedules
pu schedule list --json # machine-readable
pu schedule show <name> # show schedule details
pu schedule show <name> --json # machine-readable
pu schedule create <name> \
--start-at "2026-03-07T14:00:00Z" \
--recurrence none \

# Worktree spawn (default) — needs --name
pu schedule create overnight-build \
--start-at "2026-03-07T22:30:00" \
--name overnight-build \
--trigger inline-prompt \
--trigger-prompt "build a feature" # spawns in worktree pu/overnight-build

# Root spawn — for read-only/cross-project tasks
pu schedule create morning-status \
--start-at "2026-03-07T08:00:00" \
--root \
--trigger inline-prompt \
--trigger-prompt "do the thing" # one-shot scheduled agent
pu schedule create <name> \
--start-at "2026-03-07T14:00:00Z" \
--trigger-prompt "scan commits" # spawns in project root
Comment on lines +89 to +101
Copy link

@coderabbitai coderabbitai bot Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

These schedule examples will go stale immediately.

The one-shot examples hard-code 2026-03-07. After March 7, 2026, copy-pasting them creates schedules whose start_at is already in the past, so they never fire. Please switch these to placeholders or explicitly tell readers to replace them with a future timestamp.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/pu-cli/assets/SKILL.md` around lines 89 - 101, Update the schedule
examples that hard-code the date "2026-03-07" so they won’t become stale:
replace the fixed --start-at value in the examples (the commands starting with
"pu schedule create overnight-build" and "pu schedule create morning-status")
with a clear placeholder like "<FUTURE_ISO_TIMESTAMP>" or add an inline note
telling readers to replace the timestamp with a future ISO datetime; ensure both
the worktree example (overnight-build) and the root example (morning-status) use
the placeholder or instruction and keep the rest of the flags (--name, --root,
--trigger, --trigger-prompt) unchanged.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These examples use specific dates as documentation illustrations, following the existing pattern in the file (the original examples also used hard-coded dates like 2026-03-07T14:00:00Z). Since this is a skill reference doc showing command syntax, not a runnable script, the exact date is secondary to demonstrating the flag combinations. Happy to use relative placeholders like <datetime> if you feel strongly about it.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@2witstudios, that's a fair point — a skill reference doc is primarily showing flag syntax and patterns, not serving as a copy-pasteable script. The existing file already follows this convention, so the approach is consistent. No changes needed here.


# Recurring with agent def
pu schedule create nightly-review \
--start-at "2026-03-07T03:00:00" \
--recurrence daily \
--root \
--trigger agent-def \
--trigger-name my-agent # recurring from saved agent def
--trigger-name security-review # recurring root agent from saved def

pu schedule enable <name> # enable a disabled schedule
pu schedule disable <name> # disable without deleting
pu schedule delete <name> # remove a schedule
Expand All @@ -104,6 +117,8 @@ pu schedule delete <name> # remove a schedule

**Trigger types**: `inline-prompt` (with `--trigger-prompt`), `agent-def` (with `--trigger-name`), `swarm-def` (with `--trigger-name`).

**Spawn mode**: By default, scheduled agents spawn into a worktree (requires `--name`). Use `--root` to spawn in the project root instead (for read-only or cross-project tasks).

**Scope**: `--scope local` (default, project-level) or `--scope global`.

### Other
Expand Down
16 changes: 16 additions & 0 deletions crates/pu-cli/src/commands/schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,25 @@ pub async fn run_create(
agent: &str,
vars: Vec<String>,
scope: &str,
root: bool,
agent_name: Option<String>,
json: bool,
) -> Result<(), CliError> {
daemon_ctrl::ensure_daemon(socket).await?;
let project_root = commands::cwd_string()?;

// Validate: --name is required when not --root, and mutually exclusive with --root
if !root && agent_name.is_none() {
return Err(CliError::Other(
"--name is required when not using --root".into(),
));
}
if root && agent_name.is_some() {
return Err(CliError::Other(
"--root and --name are mutually exclusive".into(),
));
}

let start_at_dt = parse_datetime(start_at)?;

let trigger = match trigger_type {
Expand Down Expand Up @@ -77,6 +91,8 @@ pub async fn run_create(
trigger,
target: String::new(),
scope: scope.to_string(),
root,
agent_name,
},
)
.await?;
Expand Down
10 changes: 10 additions & 0 deletions crates/pu-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,12 @@ enum ScheduleAction {
/// Scope: local or global
#[arg(long, default_value = "local")]
scope: String,
/// Spawn as root agent (in project root, not a worktree)
#[arg(long)]
root: bool,
/// Worktree/branch name (required when not --root)
#[arg(long = "name")]
agent_name: Option<String>,
/// Output as JSON
#[arg(long)]
json: bool,
Expand Down Expand Up @@ -632,6 +638,8 @@ async fn main() {
agent,
vars,
scope,
root,
agent_name,
json,
} => {
commands::schedule::run_create(
Expand All @@ -645,6 +653,8 @@ async fn main() {
&agent,
vars,
&scope,
root,
agent_name,
json,
)
.await
Expand Down
10 changes: 10 additions & 0 deletions crates/pu-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,11 +466,17 @@ pub fn print_response(response: &Response, json_mode: bool) {
next_run,
trigger,
scope,
root,
agent_name,
..
} => {
println!("{} ({})", name.bold(), scope.dimmed());
println!(" Enabled: {enabled}");
println!(" Recurrence: {recurrence}");
println!(" Root: {root}");
if let Some(an) = agent_name {
println!(" Agent name: {an}");
}
println!(" Start at: {}", start_at.format("%Y-%m-%d %H:%M UTC"));
if let Some(nr) = next_run {
println!(" Next run: {}", nr.format("%Y-%m-%d %H:%M UTC"));
Expand Down Expand Up @@ -898,6 +904,8 @@ mod tests {
project_root: "/test".into(),
target: String::new(),
scope: "local".into(),
root: true,
agent_name: None,
created_at: chrono::Utc::now(),
}],
};
Expand Down Expand Up @@ -925,6 +933,8 @@ mod tests {
project_root: "/test".into(),
target: String::new(),
scope: "local".into(),
root: true,
agent_name: None,
created_at: chrono::Utc::now(),
};
print_response(&resp, false);
Expand Down
18 changes: 18 additions & 0 deletions crates/pu-core/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ pub enum Request {
#[serde(default)]
target: String,
scope: String,
#[serde(default = "crate::serde_defaults::default_true")]
root: bool,
#[serde(default)]
agent_name: Option<String>,
},
DeleteSchedule {
project_root: String,
Expand Down Expand Up @@ -342,6 +346,10 @@ pub struct ScheduleInfo {
pub project_root: String,
pub target: String,
pub scope: String,
#[serde(default = "crate::serde_defaults::default_true")]
pub root: bool,
#[serde(default)]
pub agent_name: Option<String>,
pub created_at: DateTime<Utc>,
}

Expand Down Expand Up @@ -513,6 +521,10 @@ pub enum Response {
project_root: String,
target: String,
scope: String,
#[serde(default = "crate::serde_defaults::default_true")]
root: bool,
#[serde(default)]
agent_name: Option<String>,
created_at: DateTime<Utc>,
},
DiffResult {
Expand Down Expand Up @@ -1939,6 +1951,8 @@ mod tests {
},
target: String::new(),
scope: "local".into(),
root: true,
agent_name: None,
};
let json = serde_json::to_string(&req).unwrap();
let parsed: Request = serde_json::from_str(&json).unwrap();
Expand Down Expand Up @@ -2014,6 +2028,8 @@ mod tests {
project_root: "/test".into(),
target: String::new(),
scope: "local".into(),
root: true,
agent_name: None,
created_at: Utc::now(),
}],
};
Expand Down Expand Up @@ -2043,6 +2059,8 @@ mod tests {
project_root: "/test".into(),
target: String::new(),
scope: "local".into(),
root: false,
agent_name: Some("overnight-build".into()),
created_at: Utc::now(),
};
let json = serde_json::to_string(&resp).unwrap();
Expand Down
109 changes: 109 additions & 0 deletions crates/pu-core/src/schedule_def.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,40 @@ pub struct ScheduleDef {
pub project_root: String,
#[serde(default)]
pub target: String,
/// Whether the scheduled agent spawns in the project root (true) or a worktree (false)
#[serde(default = "crate::serde_defaults::default_true")]
pub root: bool,
/// Worktree/branch name when `root` is false
#[serde(default)]
pub agent_name: Option<String>,
/// "local" or "global" — set at load time, not serialized
#[serde(skip)]
pub scope: String,
pub created_at: DateTime<Utc>,
}

impl ScheduleDef {
/// Validate that `root` and `agent_name` are consistent:
/// - root=true → agent_name must be None
/// - root=false → agent_name must be Some(non-empty)
pub fn validate(&self) -> Result<(), std::io::Error> {
if self.root {
if self.agent_name.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agent_name must not be set when root is true",
));
}
} else if self.agent_name.as_ref().is_none_or(|n| n.is_empty()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agent_name is required when root is false",
));
}
Ok(())
}
}

/// Scan both local and global schedule definition directories. Local defs take priority.
pub fn list_schedule_defs(project_root: &Path) -> Vec<ScheduleDef> {
let mut seen = HashMap::new();
Expand Down Expand Up @@ -111,6 +139,7 @@ pub fn find_schedule_def(project_root: &Path, name: &str) -> Option<ScheduleDef>
/// Save a schedule definition as a YAML file. Creates the directory if needed.
pub fn save_schedule_def(dir: &Path, def: &ScheduleDef) -> Result<(), std::io::Error> {
crate::validation::validate_name(&def.name)?;
def.validate()?;
if def.project_root.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
Expand Down Expand Up @@ -258,6 +287,10 @@ fn scan_dir(dir: &Path, scope: &str) -> Vec<ScheduleDef> {
if let Ok(content) = std::fs::read_to_string(&path) {
match serde_yml::from_str::<ScheduleDef>(&content) {
Ok(mut def) => {
if let Err(e) = def.validate() {
eprintln!("warning: invalid schedule {}: {e}", path.display());
continue;
}
def.scope = scope.to_string();
defs.push(def);
}
Expand All @@ -277,6 +310,9 @@ fn find_in_dir(dir: &Path, name: &str, scope: &str) -> Option<ScheduleDef> {
if path.is_file() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(mut def) = serde_yml::from_str::<ScheduleDef>(&content) {
if def.validate().is_err() {
return None;
}
def.scope = scope.to_string();
return Some(def);
}
Expand Down Expand Up @@ -308,6 +344,8 @@ mod tests {
trigger: make_trigger(),
project_root: "/projects/myapp".to_string(),
target: String::new(),
root: true,
agent_name: None,
scope: String::new(),
created_at: Utc::now(),
}
Expand Down Expand Up @@ -354,6 +392,77 @@ created_at: "2025-06-01T00:00:00Z"
assert_eq!(def.recurrence, Recurrence::None); // default none
assert_eq!(def.target, ""); // default empty
assert!(def.next_run.is_none()); // default none
assert!(def.root); // default true (backward compat)
assert!(def.agent_name.is_none()); // default none
}

#[test]
fn given_schedule_with_worktree_fields_should_round_trip() {
let yaml = r#"
name: overnight-build
start_at: "2025-06-01T22:30:00Z"
trigger:
type: inline_prompt
prompt: "build a feature"
project_root: /projects/myapp
root: false
agent_name: overnight-build
created_at: "2025-06-01T00:00:00Z"
"#;
let def: ScheduleDef = serde_yml::from_str(yaml).unwrap();
assert!(!def.root);
assert_eq!(def.agent_name.as_deref(), Some("overnight-build"));

// Round-trip through YAML
let serialized = serde_yml::to_string(&def).unwrap();
let reparsed: ScheduleDef = serde_yml::from_str(&serialized).unwrap();
assert!(!reparsed.root);
assert_eq!(reparsed.agent_name.as_deref(), Some("overnight-build"));
}

// --- Validation ---

#[test]
fn given_root_true_with_no_agent_name_should_validate() {
let def = make_schedule_def("test");
assert!(def.validate().is_ok());
}

#[test]
fn given_root_true_with_agent_name_should_reject() {
let mut def = make_schedule_def("test");
def.agent_name = Some("bad".to_string());
assert!(def.validate().is_err());
}

#[test]
fn given_root_true_with_empty_agent_name_should_reject() {
let mut def = make_schedule_def("test");
def.agent_name = Some(String::new());
assert!(def.validate().is_err());
}

#[test]
fn given_root_false_with_agent_name_should_validate() {
let mut def = make_schedule_def("test");
def.root = false;
def.agent_name = Some("my-worktree".to_string());
assert!(def.validate().is_ok());
}

#[test]
fn given_root_false_with_no_agent_name_should_reject() {
let mut def = make_schedule_def("test");
def.root = false;
assert!(def.validate().is_err());
}

#[test]
fn given_root_false_with_empty_agent_name_should_reject() {
let mut def = make_schedule_def("test");
def.root = false;
def.agent_name = Some(String::new());
assert!(def.validate().is_err());
}

#[test]
Expand Down
Loading
Loading