Skip to main content

devflow_core/
ship.rs

1//! Ship bookkeeping.
2//!
3//! Holds the Hermes cron-instructions manifest (used to resume a rate-limited
4//! DevFlow run later) plus the pure document-finalization transform
5//! (CHANGELOG) used on ship completion.
6
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10/// Manifest consumed by Hermes to resume a rate-limited DevFlow run later.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct CronInstructions {
13    /// Absolute project root.
14    pub project: String,
15    /// Phase that should resume.
16    pub phase: u32,
17    /// Current handoff status, e.g. "rate_limited".
18    pub status: String,
19    /// Upstream retry timestamp or description.
20    pub retry_after: String,
21    /// DevFlow resume command.
22    pub resume: ResumeCommand,
23    /// Hermes cron job definition derived from the retry timestamp.
24    pub hermes_cron: HermesCronJob,
25}
26
27/// Command + args that resume the DevFlow workflow.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct ResumeCommand {
30    /// Executable name.
31    pub command: String,
32    /// Command arguments.
33    pub args: Vec<String>,
34}
35
36/// Hermes one-shot cron job payload.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct HermesCronJob {
39    /// Cron schedule in `M H D M W` format.
40    pub schedule: String,
41    /// Stable job name.
42    pub name: String,
43    /// Shell command to execute.
44    pub command: String,
45    /// Whether Hermes should remove the job after it runs.
46    pub once: bool,
47}
48
49/// Errors produced by ship bookkeeping.
50#[derive(Debug, thiserror::Error)]
51pub enum ShipError {
52    /// Filesystem operation failed.
53    #[error("ship I/O failed: {0}")]
54    Io(#[from] std::io::Error),
55    /// JSON parse or serialization failed.
56    #[error("ship JSON failed: {0}")]
57    Json(#[from] serde_json::Error),
58    /// No last-ship record exists.
59    #[error("no last-ship record found — nothing to confirm or reject")]
60    Missing,
61}
62
63/// Path to a phase's cron-instructions record. Per-phase since 14a
64/// (13-DEFERRED-CR-03): the old single-slot `cron-instructions.json` let one
65/// phase's rate-limit record clobber another's under `devflow parallel`.
66pub fn cron_instructions_path(project_root: &Path, phase: u32) -> PathBuf {
67    project_root
68        .join(".devflow")
69        .join(format!("cron-instructions-{phase:02}.json"))
70}
71
72/// Path of the legacy single-slot record written by pre-14a binaries. Still
73/// read/deleted for compatibility; never written.
74pub(crate) fn legacy_cron_instructions_path(project_root: &Path) -> PathBuf {
75    project_root.join(".devflow").join("cron-instructions.json")
76}
77
78/// Persist Hermes cron instructions for the phase recorded inside them.
79pub fn write_cron_instructions(
80    project_root: &Path,
81    instructions: &CronInstructions,
82) -> Result<(), ShipError> {
83    let path = cron_instructions_path(project_root, instructions.phase);
84    if let Some(parent) = path.parent() {
85        crate::workflow::ensure_devflow_dir(parent)?;
86    }
87    std::fs::write(&path, serde_json::to_string_pretty(instructions)?)?;
88    Ok(())
89}
90
91/// Load a phase's Hermes cron instructions, or [`ShipError::Missing`] if
92/// absent. Falls back to a legacy single-slot record when it names this phase.
93pub fn load_cron_instructions(
94    project_root: &Path,
95    phase: u32,
96) -> Result<CronInstructions, ShipError> {
97    let path = cron_instructions_path(project_root, phase);
98    if path.exists() {
99        return Ok(serde_json::from_str(&std::fs::read_to_string(&path)?)?);
100    }
101    let legacy = legacy_cron_instructions_path(project_root);
102    if legacy.exists() {
103        let instructions: CronInstructions =
104            serde_json::from_str(&std::fs::read_to_string(&legacy)?)?;
105        if instructions.phase == phase {
106            return Ok(instructions);
107        }
108    }
109    Err(ShipError::Missing)
110}
111
112/// Every pending cron-instructions record (per-phase files plus a legacy
113/// single-slot one), sorted by phase. Unparsable files are skipped.
114pub fn list_cron_instructions(project_root: &Path) -> Vec<CronInstructions> {
115    let mut found = Vec::new();
116    if let Ok(entries) = std::fs::read_dir(project_root.join(".devflow")) {
117        for entry in entries.flatten() {
118            let name = entry.file_name();
119            let Some(name) = name.to_str() else { continue };
120            if !name.starts_with("cron-instructions") || !name.ends_with(".json") {
121                continue;
122            }
123            if let Ok(contents) = std::fs::read_to_string(entry.path())
124                && let Ok(instructions) = serde_json::from_str::<CronInstructions>(&contents)
125            {
126                found.push(instructions);
127            }
128        }
129    }
130    found.sort_by_key(|i| i.phase);
131    found.dedup_by_key(|i| i.phase);
132    found
133}
134
135/// Remove a phase's cron-instructions record (and a legacy single-slot record
136/// naming the same phase). Idempotent.
137pub fn delete_cron_instructions(project_root: &Path, phase: u32) -> Result<(), ShipError> {
138    let path = cron_instructions_path(project_root, phase);
139    if path.exists() {
140        std::fs::remove_file(path)?;
141    }
142    let legacy = legacy_cron_instructions_path(project_root);
143    if legacy.exists()
144        && let Ok(contents) = std::fs::read_to_string(&legacy)
145        && serde_json::from_str::<CronInstructions>(&contents)
146            .map(|i| i.phase == phase)
147            .unwrap_or(true)
148    {
149        std::fs::remove_file(&legacy)?;
150    }
151    Ok(())
152}
153
154/// Build a Hermes cron-instructions manifest for resuming the PRIMARY
155/// single-agent `advance()` monitor loop (D-09, review consensus #5) via
156/// `devflow resume --phase N`. `agent` is intentionally omitted from the
157/// resume command: `devflow resume` loads it (along with mode and stage)
158/// from the phase's saved state.
159pub fn build_single_agent_cron_instructions(
160    project_root: &Path,
161    phase: u32,
162    retry_after: &str,
163) -> CronInstructions {
164    let project = project_root.display().to_string();
165    let args = vec![
166        "resume".to_string(),
167        "--phase".to_string(),
168        phase.to_string(),
169    ];
170    CronInstructions {
171        project: project.clone(),
172        phase,
173        status: "rate_limited".to_string(),
174        retry_after: retry_after.to_string(),
175        resume: ResumeCommand {
176            command: "devflow".to_string(),
177            args,
178        },
179        hermes_cron: HermesCronJob {
180            schedule: cron_schedule_from_retry_after(retry_after).unwrap_or_default(),
181            name: format!("devflow-phase-{phase:02}-resume"),
182            command: format!(
183                "cd {} && devflow resume --phase {phase}",
184                shell_quote(&project)
185            ),
186            once: true,
187        },
188    }
189}
190
191/// Convert a retry timestamp to `M H D M W` cron syntax, rounding up to the
192/// nearest minute. Supports RFC3339-like timestamps and Unix epoch seconds.
193pub fn cron_schedule_from_retry_after(retry_after: &str) -> Option<String> {
194    // WR-06: never turn unparseable agent output into an every-minute cron.
195    parse_retry_timestamp(retry_after).map(|ts| ts.round_up_minute().to_cron())
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199struct RetryTimestamp {
200    year: i32,
201    month: u32,
202    day: u32,
203    hour: u32,
204    minute: u32,
205    second: u32,
206}
207
208impl RetryTimestamp {
209    fn round_up_minute(self) -> Self {
210        if self.second == 0 {
211            return self;
212        }
213        Self::from_epoch_minutes(self.to_epoch_minutes() + 1)
214    }
215
216    fn to_cron(self) -> String {
217        format!(
218            "{} {} {} {} *",
219            self.minute, self.hour, self.day, self.month
220        )
221    }
222
223    fn to_epoch_minutes(self) -> i64 {
224        let days = days_from_civil(self.year, self.month, self.day);
225        days * 24 * 60 + i64::from(self.hour) * 60 + i64::from(self.minute)
226    }
227
228    fn from_epoch_minutes(minutes: i64) -> Self {
229        let days = minutes.div_euclid(24 * 60);
230        let minute_of_day = minutes.rem_euclid(24 * 60);
231        let (year, month, day) = civil_from_days(days);
232        Self {
233            year,
234            month,
235            day,
236            hour: (minute_of_day / 60) as u32,
237            minute: (minute_of_day % 60) as u32,
238            second: 0,
239        }
240    }
241}
242
243fn parse_retry_timestamp(input: &str) -> Option<RetryTimestamp> {
244    parse_unix_seconds(input).or_else(|| parse_rfc3339ish(input))
245}
246
247fn parse_unix_seconds(input: &str) -> Option<RetryTimestamp> {
248    let seconds = input.trim().parse::<i64>().ok()?;
249    let minutes = seconds.div_euclid(60) + i64::from(seconds.rem_euclid(60) > 0);
250    Some(RetryTimestamp::from_epoch_minutes(minutes))
251}
252
253fn parse_rfc3339ish(input: &str) -> Option<RetryTimestamp> {
254    let input = input.trim();
255    let split_at = input.find('T').or_else(|| input.find(' '))?;
256    let (date, rest) = input.split_at(split_at);
257    let time = rest.get(1..)?;
258    let mut date_parts = date.split('-');
259    let year = date_parts.next()?.parse::<i32>().ok()?;
260    let month = date_parts.next()?.parse::<u32>().ok()?;
261    let day = date_parts.next()?.parse::<u32>().ok()?;
262    if date_parts.next().is_some() {
263        return None;
264    }
265
266    let (time, offset_minutes) = split_time_and_offset(time);
267    let mut time_parts = time.split(':');
268    let hour = time_parts.next()?.parse::<u32>().ok()?;
269    let minute = time_parts.next()?.parse::<u32>().ok()?;
270    let second = time_parts
271        .next()
272        .map(|s| s.split('.').next().unwrap_or_default().parse::<u32>().ok())
273        .unwrap_or(Some(0))?;
274    if month == 0 || month > 12 || day == 0 || day > 31 || hour > 23 || minute > 59 || second > 60 {
275        return None;
276    }
277
278    let ts = RetryTimestamp {
279        year,
280        month,
281        day,
282        hour,
283        minute,
284        second,
285    };
286    let utc_minutes = ts.to_epoch_minutes() - i64::from(offset_minutes);
287    let mut normalized = RetryTimestamp::from_epoch_minutes(utc_minutes);
288    // `to_epoch_minutes`/`from_epoch_minutes` normalize at whole-minute
289    // granularity (the offset subtraction above only ever shifts whole
290    // minutes, since `offset_minutes` is itself an integer minute count),
291    // so `from_epoch_minutes` always zeroes `second`. A timezone offset never
292    // carries a sub-minute component, so the original `second` is
293    // timezone-invariant and safe to restore verbatim here.
294    normalized.second = second;
295    Some(normalized)
296}
297
298fn split_time_and_offset(time: &str) -> (&str, i32) {
299    let trimmed = time.trim_end_matches('Z');
300    if trimmed.len() > 6 {
301        if let Some(idx) = trimmed.rfind('+') {
302            return (
303                &trimmed[..idx],
304                parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
305            );
306        }
307        if let Some(idx) = trimmed.rfind('-')
308            && idx > 0
309        {
310            return (
311                &trimmed[..idx],
312                parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
313            );
314        }
315    }
316    (trimmed, 0)
317}
318
319fn parse_offset_minutes(offset: &str) -> Option<i32> {
320    // WR-07 (13-REVIEW.md), revised: accept the three ISO-8601 offset forms
321    // — ±HH:MM, ±HHMM, and hour-only ±HH — with bound-checked values.
322    // Requiring a colon (the first WR-07 fix) silently rescheduled valid
323    // ±HH/±HHMM timestamps to UTC through the callers' `unwrap_or(0)`,
324    // firing the resume cron hours off; the original pre-WR-07 code misread
325    // ±HHMM as HHMM *hours*. Anything else (wrong digit count, out-of-range
326    // values) still fails safe as None. `retry_after` is raw agent output,
327    // so no producer guarantees one form.
328    const MAX_OFFSET_HOURS: i32 = 23;
329    const MAX_OFFSET_MINUTES: i32 = 59;
330    let sign = if offset.starts_with('-') { -1 } else { 1 };
331    let rest = offset.get(1..)?;
332    let (hours_part, minutes_part) = match rest.split_once(':') {
333        Some((hours, minutes)) => (hours, minutes),
334        None => match rest.len() {
335            2 => (rest, "0"),              // ±HH
336            4 => (&rest[..2], &rest[2..]), // ±HHMM
337            _ => return None,
338        },
339    };
340    let hours = hours_part.parse::<i32>().ok()?;
341    let minutes = minutes_part.parse::<i32>().ok()?;
342    if !(0..=MAX_OFFSET_HOURS).contains(&hours) || !(0..=MAX_OFFSET_MINUTES).contains(&minutes) {
343        return None;
344    }
345    Some(sign * (hours * 60 + minutes))
346}
347
348fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
349    let year = year - i32::from(month <= 2);
350    let era = i64::from(year).div_euclid(400);
351    let yoe = i64::from(year) - era * 400;
352    let month = i64::from(month);
353    let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
354    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
355    era * 146_097 + doe - 719_468
356}
357
358fn civil_from_days(days: i64) -> (i32, u32, u32) {
359    let z = days + 719_468;
360    let era = z.div_euclid(146_097);
361    let doe = z - era * 146_097;
362    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096).div_euclid(365);
363    let year = yoe + era * 400;
364    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
365    let mp = (5 * doy + 2).div_euclid(153);
366    let day = doy - (153 * mp + 2).div_euclid(5) + 1;
367    let month = mp + if mp < 10 { 3 } else { -9 };
368    let year = year + i64::from(month <= 2);
369    (year as i32, month as u32, day as u32)
370}
371
372fn shell_quote(value: &str) -> String {
373    // Characters that never need quoting in a POSIX shell word: alphanumerics
374    // plus the common punctuation used in paths, versions, and identifiers
375    // (`/ . _ -`) and additional unambiguously-safe characters (`~ : @ + = %`)
376    // that have no special meaning to the shell when unquoted. Anything not
377    // in this set falls through to single-quote wrapping below, so widening
378    // this list only reduces over-quoting — it can never under-quote.
379    if value.chars().all(|c| {
380        c.is_ascii_alphanumeric()
381            || matches!(c, '/' | '.' | '_' | '-' | '~' | ':' | '@' | '+' | '=' | '%')
382    }) {
383        value.to_string()
384    } else {
385        format!("'{}'", value.replace('\'', "'\\''"))
386    }
387}
388
389/// Prepend a CHANGELOG entry for `version`, creating a standard header if the
390/// file did not exist. Pure transform over the existing CHANGELOG contents.
391///
392/// `body` is the Keep-a-Changelog-grouped content
393/// [`crate::version::render_changelog_body`] produces (D-12) — trimmed of
394/// trailing newlines and re-terminated with a single `\n`. When `body.trim()`
395/// is empty (no version-affecting content, or the caller couldn't compute
396/// one), the fallback line `- No changes recorded since the previous
397/// release.` is substituted instead, so an entry is never silently blank.
398pub fn prepend_changelog(existing: &str, version: &str, date: &str, body: &str) -> String {
399    const HEADER: &str = "# Changelog\n\n\
400        All notable changes to this project are documented here.\n";
401    const FALLBACK: &str = "- No changes recorded since the previous release.";
402    let trimmed_body = body.trim_end_matches('\n');
403    let body_content = if trimmed_body.trim().is_empty() {
404        FALLBACK
405    } else {
406        trimmed_body
407    };
408    let entry = format!("## {version} — {date}\n\n{body_content}\n");
409
410    if existing.trim().is_empty() {
411        return format!("{HEADER}\n{entry}");
412    }
413    // Insert the new entry after the header block (first blank line after the
414    // top-level title), or at the top if no header is recognized.
415    if let Some(idx) = existing.find("\n\n") {
416        let (head, tail) = existing.split_at(idx + 2);
417        format!("{head}{entry}\n{tail}")
418    } else {
419        format!("{entry}\n{existing}")
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn cron_instructions_save_load_round_trips() {
429        let dir = tempfile::tempdir().unwrap();
430        let record = build_single_agent_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z");
431
432        write_cron_instructions(dir.path(), &record).unwrap();
433
434        assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), record);
435    }
436
437    #[test]
438    fn delete_cron_instructions_is_idempotent() {
439        let dir = tempfile::tempdir().unwrap();
440        let record = build_single_agent_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z");
441        write_cron_instructions(dir.path(), &record).unwrap();
442
443        delete_cron_instructions(dir.path(), 7).unwrap();
444        assert!(!cron_instructions_path(dir.path(), 7).exists());
445        delete_cron_instructions(dir.path(), 7).unwrap();
446    }
447
448    /// 13-DEFERRED-CR-03 re-check: two phases' rate-limit records must
449    /// coexist — the old single-slot file let one clobber the other.
450    #[test]
451    fn cron_instructions_are_per_phase() {
452        let dir = tempfile::tempdir().unwrap();
453        let a = build_single_agent_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z");
454        let b = build_single_agent_cron_instructions(dir.path(), 8, "2026-06-18T16:45:30Z");
455        write_cron_instructions(dir.path(), &a).unwrap();
456        write_cron_instructions(dir.path(), &b).unwrap();
457
458        assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), a);
459        assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
460        let listed = list_cron_instructions(dir.path());
461        assert_eq!(listed.iter().map(|i| i.phase).collect::<Vec<_>>(), [7, 8]);
462
463        delete_cron_instructions(dir.path(), 7).unwrap();
464        assert!(load_cron_instructions(dir.path(), 7).is_err());
465        assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
466    }
467
468    /// Upgrade path: a legacy single-slot `cron-instructions.json` written by
469    /// an older binary is still loadable/listable/deletable for its phase.
470    #[test]
471    fn legacy_cron_instructions_are_read_and_deleted() {
472        let dir = tempfile::tempdir().unwrap();
473        let record = build_single_agent_cron_instructions(dir.path(), 5, "2026-06-18T15:45:30Z");
474        let legacy = legacy_cron_instructions_path(dir.path());
475        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
476        std::fs::write(&legacy, serde_json::to_string_pretty(&record).unwrap()).unwrap();
477
478        assert_eq!(load_cron_instructions(dir.path(), 5).unwrap(), record);
479        assert!(load_cron_instructions(dir.path(), 6).is_err());
480        assert_eq!(list_cron_instructions(dir.path()).len(), 1);
481
482        delete_cron_instructions(dir.path(), 5).unwrap();
483        assert!(!legacy.exists());
484    }
485
486    #[test]
487    fn cron_schedule_rounds_up_to_nearest_minute() {
488        assert_eq!(
489            cron_schedule_from_retry_after("2026-06-18T15:45:30Z"),
490            Some("46 15 18 6 *".to_string())
491        );
492        assert_eq!(
493            cron_schedule_from_retry_after("2026-06-18T15:45:00Z"),
494            Some("45 15 18 6 *".to_string())
495        );
496    }
497
498    #[test]
499    fn cron_schedule_normalizes_negative_offset() {
500        // 15:45:30 local at UTC-5 → 20:45:30 UTC → round up to 20:46.
501        assert_eq!(
502            cron_schedule_from_retry_after("2026-06-18T15:45:30-05:00"),
503            Some("46 20 18 6 *".to_string())
504        );
505        // 15:45:00 local at UTC-5:30 → 21:15:00 UTC, no rounding needed.
506        assert_eq!(
507            cron_schedule_from_retry_after("2026-06-18T15:45:00-05:30"),
508            Some("15 21 18 6 *".to_string())
509        );
510    }
511
512    /// WR-07 (13-REVIEW.md), revised: all three ISO-8601 offset forms must
513    /// parse to their real value. The pre-WR-07 code misread "+0530" as 530
514    /// *hours*; the first WR-07 fix rejected everything without a colon, so
515    /// valid ±HHMM and hour-only ±HH offsets silently fell back to UTC via
516    /// `split_time_and_offset`'s `unwrap_or(0)` — scheduling the resume cron
517    /// hours away from when the rate limit actually lifts.
518    #[test]
519    fn cron_schedule_parses_all_iso8601_offset_forms() {
520        // ±HHMM: 15:45:30 at +05:30 → 10:15:30 UTC → 10:16 (seconds round up).
521        assert_eq!(
522            cron_schedule_from_retry_after("2026-06-18T15:45:30+0530"),
523            cron_schedule_from_retry_after("2026-06-18T15:45:30+05:30"),
524        );
525        // Hour-only ±HH: 15:45:30 at -05 → 20:45:30 UTC → 20:46.
526        assert_eq!(
527            cron_schedule_from_retry_after("2026-06-18T15:45:30-05"),
528            Some("46 20 18 6 *".to_string())
529        );
530    }
531
532    #[test]
533    fn parse_offset_minutes_bounds_and_forms() {
534        assert_eq!(parse_offset_minutes("+05:30"), Some(330));
535        assert_eq!(parse_offset_minutes("+0530"), Some(330));
536        assert_eq!(parse_offset_minutes("-0530"), Some(-330));
537        assert_eq!(parse_offset_minutes("+05"), Some(300));
538        assert_eq!(parse_offset_minutes("-05"), Some(-300));
539        // Out-of-range and wrong digit counts fail safe.
540        assert_eq!(parse_offset_minutes("+24"), None);
541        assert_eq!(parse_offset_minutes("+05:60"), None);
542        assert_eq!(parse_offset_minutes("+5"), None);
543        assert_eq!(parse_offset_minutes("+530"), None);
544        assert_eq!(parse_offset_minutes("+abcd"), None);
545    }
546
547    #[test]
548    fn cron_schedule_formats_unix_seconds() {
549        assert_eq!(
550            cron_schedule_from_retry_after("1766678401"),
551            Some("1 16 25 12 *".to_string())
552        );
553    }
554
555    #[test]
556    fn shell_quote_leaves_common_safe_chars_unquoted() {
557        assert_eq!(
558            shell_quote("user@host:1.2.3+build"),
559            "user@host:1.2.3+build"
560        );
561        assert_eq!(shell_quote("~/proj/build=1_2%3"), "~/proj/build=1_2%3");
562    }
563
564    #[test]
565    fn shell_quote_quotes_unsafe_input() {
566        assert_eq!(shell_quote("a b"), "'a b'");
567        assert_eq!(shell_quote("it's"), "'it'\\''s'");
568    }
569
570    /// Review consensus #5: the single-agent resume record must invoke
571    /// `devflow resume --phase N` (which relaunches saved state), never the
572    /// unsafe `devflow start` (resets to Define) or the two-agent
573    /// `sequentagent` command.
574    #[test]
575    fn single_agent_cron_instructions_resume_command_is_devflow_resume() {
576        let dir = tempfile::tempdir().unwrap();
577        let record = build_single_agent_cron_instructions(dir.path(), 9, "2026-06-18T15:45:30Z");
578
579        assert_eq!(record.resume.command, "devflow");
580        assert_eq!(record.resume.args, ["resume", "--phase", "9"]);
581        assert!(
582            record
583                .hermes_cron
584                .command
585                .contains("devflow resume --phase 9")
586        );
587        assert!(!record.hermes_cron.command.contains("sequentagent"));
588        assert!(!record.hermes_cron.command.contains(" start"));
589        assert!(record.hermes_cron.once);
590    }
591
592    #[test]
593    fn cron_instructions_reject_unparseable_retry_time() {
594        let dir = tempfile::tempdir().unwrap();
595        let record = build_single_agent_cron_instructions(dir.path(), 7, "unknown");
596
597        assert_ne!(record.hermes_cron.schedule, "* * * * *");
598        assert!(record.hermes_cron.schedule.is_empty());
599    }
600
601    #[test]
602    fn prepend_changelog_creates_header_when_empty() {
603        let out = prepend_changelog("", "0.5.2", "2026-06-18", "- some change\n");
604        assert!(out.starts_with("# Changelog"));
605        assert!(out.contains("## 0.5.2 — 2026-06-18"));
606        assert!(out.contains("- some change"));
607    }
608
609    #[test]
610    fn prepend_changelog_inserts_after_header() {
611        let existing = "# Changelog\n\n## 0.5.1 — 2026-06-17\n\n- old\n";
612        let out = prepend_changelog(existing, "0.5.2", "2026-06-18", "- new change\n");
613        let new_idx = out.find("0.5.2").unwrap();
614        let old_idx = out.find("0.5.1").unwrap();
615        assert!(new_idx < old_idx, "new entry should come before old");
616        assert!(out.starts_with("# Changelog"));
617        assert!(out.contains("- new change"));
618    }
619
620    #[test]
621    fn prepend_changelog_uses_the_generated_body() {
622        let body = "### Added\n\n- add the widget endpoint\n";
623        let out = prepend_changelog("", "1.2.0", "2026-07-29", body);
624        assert!(out.contains("## 1.2.0 — 2026-07-29"));
625        assert!(out.contains("### Added"));
626        assert!(out.contains("- add the widget endpoint"));
627        // Empty-body fallback (never a silently blank entry).
628        let fallback = prepend_changelog("", "1.2.1", "2026-07-30", "");
629        assert!(fallback.contains("- No changes recorded since the previous release."));
630    }
631}