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