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        std::fs::create_dir_all(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 `sequentagent`.
155pub fn build_cron_instructions(
156    project_root: &Path,
157    phase: u32,
158    retry_after: &str,
159    next_agents: &str,
160) -> CronInstructions {
161    let project = project_root.display().to_string();
162    let args = vec![
163        "sequentagent".to_string(),
164        "--phase".to_string(),
165        phase.to_string(),
166        "--agents".to_string(),
167        next_agents.to_string(),
168    ];
169    CronInstructions {
170        project: project.clone(),
171        phase,
172        status: "rate_limited".to_string(),
173        retry_after: retry_after.to_string(),
174        resume: ResumeCommand {
175            command: "devflow".to_string(),
176            args,
177        },
178        hermes_cron: HermesCronJob {
179            schedule: cron_schedule_from_retry_after(retry_after).unwrap_or_default(),
180            name: format!("devflow-phase-{phase:02}-resume"),
181            command: format!(
182                "cd {} && devflow sequentagent --phase {phase} --agents {next_agents}",
183                shell_quote(&project)
184            ),
185            once: true,
186        },
187    }
188}
189
190/// Build a Hermes cron-instructions manifest for resuming the PRIMARY
191/// single-agent `advance()` monitor loop (D-09, review consensus #5) via
192/// `devflow resume --phase N` — distinct from [`build_cron_instructions`],
193/// which resumes the two-agent `sequentagent` handoff. `agent` is
194/// intentionally omitted from the resume command: `devflow resume` loads it
195/// (along with mode and stage) from the phase's saved state.
196pub fn build_single_agent_cron_instructions(
197    project_root: &Path,
198    phase: u32,
199    retry_after: &str,
200) -> CronInstructions {
201    let project = project_root.display().to_string();
202    let args = vec![
203        "resume".to_string(),
204        "--phase".to_string(),
205        phase.to_string(),
206    ];
207    CronInstructions {
208        project: project.clone(),
209        phase,
210        status: "rate_limited".to_string(),
211        retry_after: retry_after.to_string(),
212        resume: ResumeCommand {
213            command: "devflow".to_string(),
214            args,
215        },
216        hermes_cron: HermesCronJob {
217            schedule: cron_schedule_from_retry_after(retry_after).unwrap_or_default(),
218            name: format!("devflow-phase-{phase:02}-resume"),
219            command: format!(
220                "cd {} && devflow resume --phase {phase}",
221                shell_quote(&project)
222            ),
223            once: true,
224        },
225    }
226}
227
228/// Convert a retry timestamp to `M H D M W` cron syntax, rounding up to the
229/// nearest minute. Supports RFC3339-like timestamps and Unix epoch seconds.
230pub fn cron_schedule_from_retry_after(retry_after: &str) -> Option<String> {
231    // WR-06: never turn unparseable agent output into an every-minute cron.
232    parse_retry_timestamp(retry_after).map(|ts| ts.round_up_minute().to_cron())
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236struct RetryTimestamp {
237    year: i32,
238    month: u32,
239    day: u32,
240    hour: u32,
241    minute: u32,
242    second: u32,
243}
244
245impl RetryTimestamp {
246    fn round_up_minute(self) -> Self {
247        if self.second == 0 {
248            return self;
249        }
250        Self::from_epoch_minutes(self.to_epoch_minutes() + 1)
251    }
252
253    fn to_cron(self) -> String {
254        format!(
255            "{} {} {} {} *",
256            self.minute, self.hour, self.day, self.month
257        )
258    }
259
260    fn to_epoch_minutes(self) -> i64 {
261        let days = days_from_civil(self.year, self.month, self.day);
262        days * 24 * 60 + i64::from(self.hour) * 60 + i64::from(self.minute)
263    }
264
265    fn from_epoch_minutes(minutes: i64) -> Self {
266        let days = minutes.div_euclid(24 * 60);
267        let minute_of_day = minutes.rem_euclid(24 * 60);
268        let (year, month, day) = civil_from_days(days);
269        Self {
270            year,
271            month,
272            day,
273            hour: (minute_of_day / 60) as u32,
274            minute: (minute_of_day % 60) as u32,
275            second: 0,
276        }
277    }
278}
279
280fn parse_retry_timestamp(input: &str) -> Option<RetryTimestamp> {
281    parse_unix_seconds(input).or_else(|| parse_rfc3339ish(input))
282}
283
284fn parse_unix_seconds(input: &str) -> Option<RetryTimestamp> {
285    let seconds = input.trim().parse::<i64>().ok()?;
286    let minutes = seconds.div_euclid(60) + i64::from(seconds.rem_euclid(60) > 0);
287    Some(RetryTimestamp::from_epoch_minutes(minutes))
288}
289
290fn parse_rfc3339ish(input: &str) -> Option<RetryTimestamp> {
291    let input = input.trim();
292    let split_at = input.find('T').or_else(|| input.find(' '))?;
293    let (date, rest) = input.split_at(split_at);
294    let time = rest.get(1..)?;
295    let mut date_parts = date.split('-');
296    let year = date_parts.next()?.parse::<i32>().ok()?;
297    let month = date_parts.next()?.parse::<u32>().ok()?;
298    let day = date_parts.next()?.parse::<u32>().ok()?;
299    if date_parts.next().is_some() {
300        return None;
301    }
302
303    let (time, offset_minutes) = split_time_and_offset(time);
304    let mut time_parts = time.split(':');
305    let hour = time_parts.next()?.parse::<u32>().ok()?;
306    let minute = time_parts.next()?.parse::<u32>().ok()?;
307    let second = time_parts
308        .next()
309        .map(|s| s.split('.').next().unwrap_or_default().parse::<u32>().ok())
310        .unwrap_or(Some(0))?;
311    if month == 0 || month > 12 || day == 0 || day > 31 || hour > 23 || minute > 59 || second > 60 {
312        return None;
313    }
314
315    let ts = RetryTimestamp {
316        year,
317        month,
318        day,
319        hour,
320        minute,
321        second,
322    };
323    let utc_minutes = ts.to_epoch_minutes() - i64::from(offset_minutes);
324    let mut normalized = RetryTimestamp::from_epoch_minutes(utc_minutes);
325    // `to_epoch_minutes`/`from_epoch_minutes` normalize at whole-minute
326    // granularity (the offset subtraction above only ever shifts whole
327    // minutes, since `offset_minutes` is itself an integer minute count),
328    // so `from_epoch_minutes` always zeroes `second`. A timezone offset never
329    // carries a sub-minute component, so the original `second` is
330    // timezone-invariant and safe to restore verbatim here.
331    normalized.second = second;
332    Some(normalized)
333}
334
335fn split_time_and_offset(time: &str) -> (&str, i32) {
336    let trimmed = time.trim_end_matches('Z');
337    if trimmed.len() > 6 {
338        if let Some(idx) = trimmed.rfind('+') {
339            return (
340                &trimmed[..idx],
341                parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
342            );
343        }
344        if let Some(idx) = trimmed.rfind('-')
345            && idx > 0
346        {
347            return (
348                &trimmed[..idx],
349                parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
350            );
351        }
352    }
353    (trimmed, 0)
354}
355
356fn parse_offset_minutes(offset: &str) -> Option<i32> {
357    // WR-07 (13-REVIEW.md), revised: accept the three ISO-8601 offset forms
358    // — ±HH:MM, ±HHMM, and hour-only ±HH — with bound-checked values.
359    // Requiring a colon (the first WR-07 fix) silently rescheduled valid
360    // ±HH/±HHMM timestamps to UTC through the callers' `unwrap_or(0)`,
361    // firing the resume cron hours off; the original pre-WR-07 code misread
362    // ±HHMM as HHMM *hours*. Anything else (wrong digit count, out-of-range
363    // values) still fails safe as None. `retry_after` is raw agent output,
364    // so no producer guarantees one form.
365    const MAX_OFFSET_HOURS: i32 = 23;
366    const MAX_OFFSET_MINUTES: i32 = 59;
367    let sign = if offset.starts_with('-') { -1 } else { 1 };
368    let rest = offset.get(1..)?;
369    let (hours_part, minutes_part) = match rest.split_once(':') {
370        Some((hours, minutes)) => (hours, minutes),
371        None => match rest.len() {
372            2 => (rest, "0"),              // ±HH
373            4 => (&rest[..2], &rest[2..]), // ±HHMM
374            _ => return None,
375        },
376    };
377    let hours = hours_part.parse::<i32>().ok()?;
378    let minutes = minutes_part.parse::<i32>().ok()?;
379    if !(0..=MAX_OFFSET_HOURS).contains(&hours) || !(0..=MAX_OFFSET_MINUTES).contains(&minutes) {
380        return None;
381    }
382    Some(sign * (hours * 60 + minutes))
383}
384
385fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
386    let year = year - i32::from(month <= 2);
387    let era = i64::from(year).div_euclid(400);
388    let yoe = i64::from(year) - era * 400;
389    let month = i64::from(month);
390    let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
391    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
392    era * 146_097 + doe - 719_468
393}
394
395fn civil_from_days(days: i64) -> (i32, u32, u32) {
396    let z = days + 719_468;
397    let era = z.div_euclid(146_097);
398    let doe = z - era * 146_097;
399    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096).div_euclid(365);
400    let year = yoe + era * 400;
401    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
402    let mp = (5 * doy + 2).div_euclid(153);
403    let day = doy - (153 * mp + 2).div_euclid(5) + 1;
404    let month = mp + if mp < 10 { 3 } else { -9 };
405    let year = year + i64::from(month <= 2);
406    (year as i32, month as u32, day as u32)
407}
408
409fn shell_quote(value: &str) -> String {
410    // Characters that never need quoting in a POSIX shell word: alphanumerics
411    // plus the common punctuation used in paths, versions, and identifiers
412    // (`/ . _ -`) and additional unambiguously-safe characters (`~ : @ + = %`)
413    // that have no special meaning to the shell when unquoted. Anything not
414    // in this set falls through to single-quote wrapping below, so widening
415    // this list only reduces over-quoting — it can never under-quote.
416    if value.chars().all(|c| {
417        c.is_ascii_alphanumeric()
418            || matches!(c, '/' | '.' | '_' | '-' | '~' | ':' | '@' | '+' | '=' | '%')
419    }) {
420        value.to_string()
421    } else {
422        format!("'{}'", value.replace('\'', "'\\''"))
423    }
424}
425
426/// Prepend a CHANGELOG entry for `version`, creating a standard header if the
427/// file did not exist. Pure transform over the existing CHANGELOG contents.
428pub fn prepend_changelog(existing: &str, version: &str, date: &str) -> String {
429    const HEADER: &str = "# Changelog\n\n\
430        All notable changes to this project are documented here.\n";
431    let entry = format!("## {version} — {date}\n\n- Released phase via DevFlow.\n");
432
433    if existing.trim().is_empty() {
434        return format!("{HEADER}\n{entry}");
435    }
436    // Insert the new entry after the header block (first blank line after the
437    // top-level title), or at the top if no header is recognized.
438    if let Some(idx) = existing.find("\n\n") {
439        let (head, tail) = existing.split_at(idx + 2);
440        format!("{head}{entry}\n{tail}")
441    } else {
442        format!("{entry}\n{existing}")
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[test]
451    fn cron_instructions_save_load_round_trips() {
452        let dir = tempfile::tempdir().unwrap();
453        let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
454
455        write_cron_instructions(dir.path(), &record).unwrap();
456
457        assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), record);
458    }
459
460    #[test]
461    fn delete_cron_instructions_is_idempotent() {
462        let dir = tempfile::tempdir().unwrap();
463        let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
464        write_cron_instructions(dir.path(), &record).unwrap();
465
466        delete_cron_instructions(dir.path(), 7).unwrap();
467        assert!(!cron_instructions_path(dir.path(), 7).exists());
468        delete_cron_instructions(dir.path(), 7).unwrap();
469    }
470
471    /// 13-DEFERRED-CR-03 re-check: two phases' rate-limit records must
472    /// coexist — the old single-slot file let one clobber the other.
473    #[test]
474    fn cron_instructions_are_per_phase() {
475        let dir = tempfile::tempdir().unwrap();
476        let a = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "claude,codex");
477        let b = build_cron_instructions(dir.path(), 8, "2026-06-18T16:45:30Z", "codex,claude");
478        write_cron_instructions(dir.path(), &a).unwrap();
479        write_cron_instructions(dir.path(), &b).unwrap();
480
481        assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), a);
482        assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
483        let listed = list_cron_instructions(dir.path());
484        assert_eq!(listed.iter().map(|i| i.phase).collect::<Vec<_>>(), [7, 8]);
485
486        delete_cron_instructions(dir.path(), 7).unwrap();
487        assert!(load_cron_instructions(dir.path(), 7).is_err());
488        assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
489    }
490
491    /// Upgrade path: a legacy single-slot `cron-instructions.json` written by
492    /// an older binary is still loadable/listable/deletable for its phase.
493    #[test]
494    fn legacy_cron_instructions_are_read_and_deleted() {
495        let dir = tempfile::tempdir().unwrap();
496        let record = build_cron_instructions(dir.path(), 5, "2026-06-18T15:45:30Z", "claude,codex");
497        let legacy = legacy_cron_instructions_path(dir.path());
498        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
499        std::fs::write(&legacy, serde_json::to_string_pretty(&record).unwrap()).unwrap();
500
501        assert_eq!(load_cron_instructions(dir.path(), 5).unwrap(), record);
502        assert!(load_cron_instructions(dir.path(), 6).is_err());
503        assert_eq!(list_cron_instructions(dir.path()).len(), 1);
504
505        delete_cron_instructions(dir.path(), 5).unwrap();
506        assert!(!legacy.exists());
507    }
508
509    #[test]
510    fn cron_schedule_rounds_up_to_nearest_minute() {
511        assert_eq!(
512            cron_schedule_from_retry_after("2026-06-18T15:45:30Z"),
513            Some("46 15 18 6 *".to_string())
514        );
515        assert_eq!(
516            cron_schedule_from_retry_after("2026-06-18T15:45:00Z"),
517            Some("45 15 18 6 *".to_string())
518        );
519    }
520
521    #[test]
522    fn cron_schedule_normalizes_negative_offset() {
523        // 15:45:30 local at UTC-5 → 20:45:30 UTC → round up to 20:46.
524        assert_eq!(
525            cron_schedule_from_retry_after("2026-06-18T15:45:30-05:00"),
526            Some("46 20 18 6 *".to_string())
527        );
528        // 15:45:00 local at UTC-5:30 → 21:15:00 UTC, no rounding needed.
529        assert_eq!(
530            cron_schedule_from_retry_after("2026-06-18T15:45:00-05:30"),
531            Some("15 21 18 6 *".to_string())
532        );
533    }
534
535    /// WR-07 (13-REVIEW.md), revised: all three ISO-8601 offset forms must
536    /// parse to their real value. The pre-WR-07 code misread "+0530" as 530
537    /// *hours*; the first WR-07 fix rejected everything without a colon, so
538    /// valid ±HHMM and hour-only ±HH offsets silently fell back to UTC via
539    /// `split_time_and_offset`'s `unwrap_or(0)` — scheduling the resume cron
540    /// hours away from when the rate limit actually lifts.
541    #[test]
542    fn cron_schedule_parses_all_iso8601_offset_forms() {
543        // ±HHMM: 15:45:30 at +05:30 → 10:15:30 UTC → 10:16 (seconds round up).
544        assert_eq!(
545            cron_schedule_from_retry_after("2026-06-18T15:45:30+0530"),
546            cron_schedule_from_retry_after("2026-06-18T15:45:30+05:30"),
547        );
548        // Hour-only ±HH: 15:45:30 at -05 → 20:45:30 UTC → 20:46.
549        assert_eq!(
550            cron_schedule_from_retry_after("2026-06-18T15:45:30-05"),
551            Some("46 20 18 6 *".to_string())
552        );
553    }
554
555    #[test]
556    fn parse_offset_minutes_bounds_and_forms() {
557        assert_eq!(parse_offset_minutes("+05:30"), Some(330));
558        assert_eq!(parse_offset_minutes("+0530"), Some(330));
559        assert_eq!(parse_offset_minutes("-0530"), Some(-330));
560        assert_eq!(parse_offset_minutes("+05"), Some(300));
561        assert_eq!(parse_offset_minutes("-05"), Some(-300));
562        // Out-of-range and wrong digit counts fail safe.
563        assert_eq!(parse_offset_minutes("+24"), None);
564        assert_eq!(parse_offset_minutes("+05:60"), None);
565        assert_eq!(parse_offset_minutes("+5"), None);
566        assert_eq!(parse_offset_minutes("+530"), None);
567        assert_eq!(parse_offset_minutes("+abcd"), None);
568    }
569
570    #[test]
571    fn cron_schedule_formats_unix_seconds() {
572        assert_eq!(
573            cron_schedule_from_retry_after("1766678401"),
574            Some("1 16 25 12 *".to_string())
575        );
576    }
577
578    #[test]
579    fn shell_quote_leaves_common_safe_chars_unquoted() {
580        assert_eq!(
581            shell_quote("user@host:1.2.3+build"),
582            "user@host:1.2.3+build"
583        );
584        assert_eq!(shell_quote("~/proj/build=1_2%3"), "~/proj/build=1_2%3");
585    }
586
587    #[test]
588    fn shell_quote_quotes_unsafe_input() {
589        assert_eq!(shell_quote("a b"), "'a b'");
590        assert_eq!(shell_quote("it's"), "'it'\\''s'");
591    }
592
593    #[test]
594    fn cron_instructions_include_resume_command() {
595        let dir = tempfile::tempdir().unwrap();
596        let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
597
598        assert_eq!(record.resume.command, "devflow");
599        assert_eq!(
600            record.resume.args,
601            ["sequentagent", "--phase", "7", "--agents", "codex,claude"]
602        );
603        assert!(
604            record
605                .hermes_cron
606                .command
607                .contains("devflow sequentagent --phase 7 --agents codex,claude")
608        );
609        assert!(record.hermes_cron.once);
610    }
611
612    /// Review consensus #5: the single-agent resume record must invoke
613    /// `devflow resume --phase N` (which relaunches saved state), never the
614    /// unsafe `devflow start` (resets to Define) or the two-agent
615    /// `sequentagent` command.
616    #[test]
617    fn single_agent_cron_instructions_resume_command_is_devflow_resume() {
618        let dir = tempfile::tempdir().unwrap();
619        let record = build_single_agent_cron_instructions(dir.path(), 9, "2026-06-18T15:45:30Z");
620
621        assert_eq!(record.resume.command, "devflow");
622        assert_eq!(record.resume.args, ["resume", "--phase", "9"]);
623        assert!(
624            record
625                .hermes_cron
626                .command
627                .contains("devflow resume --phase 9")
628        );
629        assert!(!record.hermes_cron.command.contains("sequentagent"));
630        assert!(!record.hermes_cron.command.contains(" start"));
631        assert!(record.hermes_cron.once);
632    }
633
634    #[test]
635    fn cron_instructions_reject_unparseable_retry_time() {
636        let dir = tempfile::tempdir().unwrap();
637        let record = build_cron_instructions(dir.path(), 7, "unknown", "codex,claude");
638
639        assert_ne!(record.hermes_cron.schedule, "* * * * *");
640        assert!(record.hermes_cron.schedule.is_empty());
641    }
642
643    #[test]
644    fn prepend_changelog_creates_header_when_empty() {
645        let out = prepend_changelog("", "0.5.2", "2026-06-18");
646        assert!(out.starts_with("# Changelog"));
647        assert!(out.contains("## 0.5.2 — 2026-06-18"));
648    }
649
650    #[test]
651    fn prepend_changelog_inserts_after_header() {
652        let existing = "# Changelog\n\n## 0.5.1 — 2026-06-17\n\n- old\n";
653        let out = prepend_changelog(existing, "0.5.2", "2026-06-18");
654        let new_idx = out.find("0.5.2").unwrap();
655        let old_idx = out.find("0.5.1").unwrap();
656        assert!(new_idx < old_idx, "new entry should come before old");
657        assert!(out.starts_with("# Changelog"));
658    }
659}