Skip to main content

devflow_core/
gsd_config.rs

1//! The single writer of GSD's `.planning/config.json` in this codebase.
2//!
3//! **DevFlow does not own this file.** Its other keys belong to GSD and to the
4//! operator, and a live GSD process may read it at any moment during a run.
5//! This module therefore acquires exactly ONE key of write authority —
6//! `workflow._auto_chain_active`, the flag GSD's `checkpoint_handling` consults
7//! to decide whether an ordinary `gate="blocking"` checkpoint may be
8//! auto-approved — and must leave every other key, its position, and its
9//! serialized form byte-identical (35.1-01, T-35.1-02).
10//!
11//! Three properties follow from that, and each is pinned by a test rather than
12//! left to inspection:
13//!
14//! 1. **No typed struct.** The file is parsed as a bare [`serde_json::Value`].
15//!    A typed `Config`/`Workflow` round trip would silently DROP the keys this
16//!    crate does not model (`commit_docs`, `git`, `intel`, `review`,
17//!    `model_overrides`, `mempalace`), which is precisely the contract this
18//!    module exists to honour.
19//! 2. **Key order is preserved.** `serde_json`'s `preserve_order` feature is
20//!    enabled in the workspace `Cargo.toml` for this module's sake; without it
21//!    every write re-sorts the top-level keys alphabetically and turns a
22//!    tracked file into a full-file diff on every stage launch.
23//! 3. **Writing a value the file already holds is a no-op.** The file's bytes
24//!    and mtime are left untouched, so an INELIGIBLE stage launch — which
25//!    actively asserts `false` rather than merely leaving the file alone — does
26//!    not dirty a tracked file every time it runs (35.1-01 F-3).
27//!
28//! Reads are defensive (ASVS V5): a shape this module does not recognise
29//! yields the inactive default, never a panic, and a malformed or absent file
30//! is an `Err` the caller can log and skip rather than an abort that would kill
31//! a long unattended run.
32
33use crate::git::GitFlow;
34use serde_json::Value;
35use std::path::{Path, PathBuf};
36
37/// The key this module owns, and the only one it may write.
38const AUTO_CHAIN_KEY: &str = "_auto_chain_active";
39/// The object that key lives under.
40const WORKFLOW_KEY: &str = "workflow";
41
42/// Errors produced while reading or writing GSD's project config.
43///
44/// Shape follows [`crate::workflow::WorkflowError`] — `Io`/`Json` `#[from]`
45/// variants so every call site converts with `?`, plus a named variant for the
46/// "there is no file at all" case so the message says which path was missing
47/// instead of surfacing a bare `NotFound`.
48#[derive(Debug, thiserror::Error)]
49pub enum GsdConfigError {
50    /// Filesystem operation failed.
51    #[error("GSD config I/O failed: {0}")]
52    Io(#[from] std::io::Error),
53    /// JSON parse or serialization failed.
54    #[error("GSD config JSON failed: {0}")]
55    Json(#[from] serde_json::Error),
56    /// No GSD config exists at the expected path.
57    #[error("no GSD config at {0}")]
58    Missing(PathBuf),
59}
60
61/// Path of the GSD project config under a project (or worktree) root.
62///
63/// `root` is the directory whose `.planning/` is the tracked, committed one —
64/// in worktree mode that is the WORKTREE, not the main checkout, because the
65/// worktree copy is the one the agent's `check auto-mode` actually reads.
66#[must_use]
67pub fn config_path(root: &Path) -> PathBuf {
68    root.join(".planning").join("config.json")
69}
70
71/// Read the config file into a [`Value`], distinguishing "absent" from
72/// "malformed" so a caller can tell a project that never had GSD config from
73/// one whose config it must not touch.
74fn read_config(path: &Path) -> Result<Value, GsdConfigError> {
75    let contents = match std::fs::read_to_string(path) {
76        Ok(contents) => contents,
77        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
78            return Err(GsdConfigError::Missing(path.to_path_buf()));
79        }
80        Err(err) => return Err(GsdConfigError::Io(err)),
81    };
82    Ok(serde_json::from_str(&contents)?)
83}
84
85/// Whether `workflow._auto_chain_active` is currently set under `root`.
86///
87/// Deliberately mirrors GSD's own defensive-default idiom
88/// (`check-command-router.cjs:95-111`): any shape this module does not
89/// recognise — no `workflow` object, a `workflow` that is not an object, a
90/// non-boolean value — reads as the inactive default. **Never index**; indexing
91/// a missing key panics, and a panic here would kill an unattended run over a
92/// hand-edited config file (ASVS V5).
93///
94/// # Errors
95///
96/// Returns [`GsdConfigError::Missing`] when no config file exists and
97/// [`GsdConfigError::Json`] when the file is not valid JSON. A file that parses
98/// but lacks the key is NOT an error — that is the defensive default above.
99pub fn auto_chain_active(root: &Path) -> Result<bool, GsdConfigError> {
100    let value = read_config(&config_path(root))?;
101    Ok(read_flag(&value))
102}
103
104/// The defensive read, factored out so the write path uses the exact same
105/// interpretation it will later be asserted against.
106fn read_flag(value: &Value) -> bool {
107    value
108        .get(WORKFLOW_KEY)
109        .and_then(|workflow| workflow.get(AUTO_CHAIN_KEY))
110        .and_then(Value::as_bool)
111        .unwrap_or(false)
112}
113
114/// Set `workflow._auto_chain_active` under `root`, returning whether the file
115/// was actually changed.
116///
117/// Writing a value the file already holds is a genuine no-op: `Ok(false)` is
118/// returned and the file's bytes and mtime are untouched (F-3). That is what
119/// keeps the symmetric guard — which asserts `false` on every ineligible launch
120/// rather than leaving whatever it finds — from rewriting a tracked file on
121/// every stage of every run.
122///
123/// A missing `workflow` object is CREATED rather than rejected; a config that
124/// simply has not grown that key yet is a normal shape, not a corrupt one.
125///
126/// # Errors
127///
128/// Returns [`GsdConfigError::Missing`] when no config file exists,
129/// [`GsdConfigError::Json`] when the existing file is not valid JSON, and
130/// [`GsdConfigError::Io`] when the atomic write fails. In every error case the
131/// original file is left exactly as it was — the temp-write-then-`rename`
132/// idiom means a failure never leaves a truncated config behind.
133pub fn set_auto_chain_active(root: &Path, active: bool) -> Result<bool, GsdConfigError> {
134    let path = config_path(root);
135    let raw = match std::fs::read_to_string(&path) {
136        Ok(raw) => raw,
137        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
138            return Err(GsdConfigError::Missing(path));
139        }
140        Err(err) => return Err(GsdConfigError::Io(err)),
141    };
142    let mut value: Value = serde_json::from_str(&raw)?;
143
144    if read_flag(&value) == active {
145        return Ok(false);
146    }
147
148    // Insert the `workflow` object when it is absent OR is not an object at
149    // all. Replacing a non-object `workflow` is the only case in which this
150    // module overwrites something it does not own, and it is unavoidable: there
151    // is nowhere else the key can live, and leaving it would mean silently
152    // reporting success while writing nothing.
153    if !value.get(WORKFLOW_KEY).is_some_and(Value::is_object) {
154        if let Some(map) = value.as_object_mut() {
155            map.insert(
156                WORKFLOW_KEY.to_string(),
157                Value::Object(serde_json::Map::new()),
158            );
159        } else {
160            // The document's root is not an object — there is no `workflow`
161            // key to set and never was. Treat it as malformed rather than
162            // replacing the operator's file wholesale.
163            return Err(GsdConfigError::Json(serde::de::Error::custom(
164                "GSD config root is not a JSON object",
165            )));
166        }
167    }
168    value[WORKFLOW_KEY][AUTO_CHAIN_KEY] = Value::Bool(active);
169
170    let mut contents = serde_json::to_string_pretty(&value)?;
171    // `to_string_pretty` emits no trailing newline; the operator's file has
172    // one. Preserving whatever the file already used keeps the diff to the one
173    // line this module owns.
174    if raw.ends_with('\n') {
175        contents.push('\n');
176    }
177    write_atomic(&path, &contents)?;
178    Ok(true)
179}
180
181/// What [`force_clear_auto_chain`] actually did, so the CLI call sites can
182/// decide whether to be loud without re-deriving any of it.
183///
184/// Three independent facts, deliberately not collapsed into one enum: a repair
185/// can touch the working tree only, the working tree AND the branch tip, or the
186/// working tree while explicitly DECLINING the branch tip. The third is not a
187/// failure — it is the correct answer when committing would sweep in an edit
188/// DevFlow does not own (F-8) — and a call site that could not tell it apart
189/// from "nothing happened" would report the deferral as silence.
190#[derive(Debug, Default)]
191pub struct ClearOutcome {
192    /// The file on disk carried a set flag and now does not.
193    pub working_tree_repaired: bool,
194    /// The branch tip carried a set flag and a commit was made so it no longer
195    /// does. Confirmed by re-probing the tip after the commit, never inferred
196    /// from `commit_path` returning `Ok`.
197    pub committed_tree_repaired: bool,
198    /// Why the branch-tip half was NOT attempted or did not land. Populated
199    /// whenever the working tree was corrected but the committed copy was left
200    /// alone for a reason the operator needs to hear.
201    pub commit_refused: Option<String>,
202}
203
204impl ClearOutcome {
205    /// Whether either half of the repair actually changed something.
206    ///
207    /// Deliberately excludes `commit_refused`: a refusal is a separate thing to
208    /// be loud about, and folding it in here would make "we fixed something"
209    /// and "we declined to fix something" indistinguishable at the call site.
210    #[must_use]
211    pub fn repaired_anything(&self) -> bool {
212        self.working_tree_repaired || self.committed_tree_repaired
213    }
214}
215
216/// The config's path as a git pathspec — relative, because that is what
217/// `commit_path` and `git show HEAD:<path>` both need.
218const CONFIG_PATHSPEC: &str = ".planning/config.json";
219
220/// Clear `workflow._auto_chain_active` under `root` unconditionally, repairing
221/// the branch tip too when that can be done without sweeping in anything else.
222///
223/// **This is the second, independent mechanism (35.1 D-01).** The in-process
224/// [`crate::gsd_config`] guard held by `devflow`'s monitor covers a normal
225/// return, a `?` early-return and a panic-unwind — and structurally cannot
226/// cover a `SIGKILL`, because `Drop` never runs. A killed monitor therefore
227/// leaves a set flag in a TRACKED file, from where `commit_docs` or any
228/// sweeping `git add` can carry it onto `develop` and into the next phase's
229/// `plan-phase` invocation, where the same boolean no longer means "approve
230/// this checkpoint" but "chain into execute-phase". Rather than try to make
231/// the guard cover the uncoverable, both launch entry points repair forward.
232///
233/// Emits nothing and prints nothing: this is `devflow-core` and it returns a
234/// report. The operator-facing notice and the `events.jsonl` entry belong to
235/// the CLI call sites, which is what lets this function's tests assert on an
236/// outcome value rather than on captured stdout.
237///
238/// # Errors
239///
240/// Returns [`GsdConfigError::Json`] when the config exists but cannot be
241/// parsed — a file DevFlow cannot read cannot be certified clear, so the caller
242/// must hear about it. An ABSENT config is not an error: a project with no GSD
243/// config has nothing to leak, and failing here would break `devflow start` for
244/// every non-GSD project.
245pub fn force_clear_auto_chain(root: &Path) -> Result<ClearOutcome, GsdConfigError> {
246    let mut outcome = ClearOutcome::default();
247
248    match set_auto_chain_active(root, false) {
249        Ok(changed) => outcome.working_tree_repaired = changed,
250        // Absent and malformed are different facts. A project with no GSD
251        // config has nothing to leak; a config that cannot be parsed cannot be
252        // certified clean and must reach the caller.
253        Err(GsdConfigError::Missing(_)) => return Ok(outcome),
254        Err(err) => return Err(err),
255    }
256
257    // F-7: ask the branch tip whether it still disagrees with the corrected
258    // file, in one call, rather than parsing HEAD's copy a second time. This
259    // answers correctly in BOTH directions that matter — a working tree that
260    // was already clear but a HEAD that carries the leak still shows a
261    // difference and still gets repaired.
262    match probe_head(root) {
263        HeadProbe::Agrees => return Ok(outcome),
264        HeadProbe::Differs => {}
265        HeadProbe::Unknown(reason) => {
266            // Unreachable is not absent. A probe that could not run tells us
267            // nothing about the tip, and assuming agreement from a failed
268            // measurement is exactly the class of error this project runs on
269            // negative controls to avoid.
270            outcome.commit_refused = Some(reason);
271            return Ok(outcome);
272        }
273    }
274
275    // F-8 / T-35.1-08: `commit_path` is path-scoped but still commits whatever
276    // else is dirty IN that path. Compare HEAD's copy against the corrected
277    // working copy with the one key DevFlow owns removed from both; if the
278    // remainder differs, the file carries an edit DevFlow was not asked to
279    // touch. Refuse — the working-tree clear already disarmed the bypass for
280    // THIS run, so all that is deferred is the branch-tip half, and deferring
281    // it visibly is strictly better than committing an operator's unfinished
282    // work.
283    let head_text = match head_copy(root) {
284        Ok(text) => text,
285        Err(reason) => {
286            outcome.commit_refused = Some(reason);
287            return Ok(outcome);
288        }
289    };
290    let working_text = std::fs::read_to_string(config_path(root))?;
291    match (
292        serde_json::from_str::<Value>(&head_text),
293        serde_json::from_str::<Value>(&working_text),
294    ) {
295        (Ok(mut head), Ok(mut working)) => {
296            without_flag(&mut head);
297            without_flag(&mut working);
298            if head != working {
299                outcome.commit_refused = Some(format!(
300                    "{CONFIG_PATHSPEC} carries changes beyond the chain flag — the \
301                     branch-tip repair was deferred rather than sweep an unrelated \
302                     edit into a DevFlow commit"
303                ));
304                return Ok(outcome);
305            }
306        }
307        _ => {
308            outcome.commit_refused = Some(format!(
309                "could not parse both copies of {CONFIG_PATHSPEC} — the branch-tip \
310                 repair was deferred rather than committed unverified"
311            ));
312            return Ok(outcome);
313        }
314    }
315
316    // F-9: a commit failure is a loud warning, not a run-killer. Hooks, a
317    // detached HEAD, an unexpected git state — any of these can make this fail,
318    // and the working tree is already repaired, so the bypass is already off.
319    // Do not "harden" this into a `?`.
320    if let Err(err) = GitFlow::new(root).commit_path(CONFIG_PATHSPEC, REPAIR_COMMIT_MESSAGE) {
321        outcome.commit_refused = Some(format!(
322            "the branch-tip repair could not be committed ({err}) — the working \
323             tree is corrected, but this branch still carries the leaked value"
324        ));
325        return Ok(outcome);
326    }
327
328    // Confirm rather than assume. `commit_path` converts git's "nothing to
329    // commit" into `Ok(())`, so a bare `Ok` is not evidence that the tip moved;
330    // re-probing is what makes `committed_tree_repaired` a measurement.
331    match probe_head(root) {
332        HeadProbe::Agrees => outcome.committed_tree_repaired = true,
333        HeadProbe::Differs => {
334            outcome.commit_refused = Some(format!(
335                "the branch-tip repair reported success but {CONFIG_PATHSPEC} still \
336                 disagrees with HEAD — this branch may still carry the leaked value"
337            ));
338        }
339        HeadProbe::Unknown(reason) => outcome.commit_refused = Some(reason),
340    }
341    Ok(outcome)
342}
343
344/// The commit the repair writes on the operator's behalf. A Conventional
345/// Commit subject under 72 characters, and a body that says what a stale flag
346/// MEANS rather than merely that a value changed.
347const REPAIR_COMMIT_MESSAGE: &str = "\
348fix(gsd): clear a leaked auto-chain flag before launch
349
350A previous run for this phase was killed before its in-process guard could
351clear workflow._auto_chain_active, and the leaked value reached this branch.
352Left in place it travels through Ship into develop, where a later phase's
353plan-phase invocation reads the same boolean as \"chain into execute-phase\"
354rather than \"approve this checkpoint\".
355
356Repaired forward by devflow start/resume (35.1 D-01).";
357
358/// What the branch tip says about the corrected file. Three answers, not two:
359/// a probe that could not run is its own case and must never collapse into
360/// "agrees".
361enum HeadProbe {
362    /// The tip already matches the corrected working copy.
363    Agrees,
364    /// The tip still differs — there is a branch-tip repair to make.
365    Differs,
366    /// The question could not be answered; the reason is operator-facing.
367    Unknown(String),
368}
369
370/// `git diff --quiet HEAD -- .planning/config.json`, read by exit code: 0 means
371/// no difference, 1 means a difference, and anything else (128 for "not a
372/// repository", "unknown revision", and friends) means the probe itself failed.
373fn probe_head(root: &Path) -> HeadProbe {
374    match crate::git::git_command(root)
375        .args(["diff", "--quiet", "HEAD", "--", CONFIG_PATHSPEC])
376        .output()
377    {
378        Ok(output) => match output.status.code() {
379            Some(0) => HeadProbe::Agrees,
380            Some(1) => HeadProbe::Differs,
381            other => HeadProbe::Unknown(format!(
382                "could not compare {CONFIG_PATHSPEC} against HEAD (git exited \
383                 {other:?}) — the branch-tip repair was deferred rather than \
384                 assumed unnecessary"
385            )),
386        },
387        Err(err) => HeadProbe::Unknown(format!(
388            "could not run git to compare {CONFIG_PATHSPEC} against HEAD ({err}) — \
389             the branch-tip repair was deferred rather than assumed unnecessary"
390        )),
391    }
392}
393
394/// HEAD's copy of the config as text, or an operator-facing reason it could not
395/// be read. A tip that holds no readable copy is a refusal, not a licence to
396/// commit blind.
397fn head_copy(root: &Path) -> Result<String, String> {
398    match crate::git::git_command(root)
399        .args(["show", &format!("HEAD:{CONFIG_PATHSPEC}")])
400        .output()
401    {
402        Ok(output) if output.status.success() => {
403            Ok(String::from_utf8_lossy(&output.stdout).into_owned())
404        }
405        Ok(_) => Err(format!(
406            "HEAD holds no readable {CONFIG_PATHSPEC} to compare against — the \
407             branch-tip repair was deferred rather than committed unverified"
408        )),
409        Err(err) => Err(format!(
410            "could not read HEAD's copy of {CONFIG_PATHSPEC} ({err}) — the \
411             branch-tip repair was deferred rather than committed unverified"
412        )),
413    }
414}
415
416/// Remove the one key DevFlow owns, so what remains is exactly the part of the
417/// file that belongs to GSD and the operator.
418fn without_flag(value: &mut Value) {
419    if let Some(workflow) = value.get_mut(WORKFLOW_KEY).and_then(Value::as_object_mut) {
420        workflow.remove(AUTO_CHAIN_KEY);
421    }
422}
423
424/// Write through a sibling temporary file so a live GSD process never observes
425/// a truncated or partially written config (T-35.1-04) — the same idiom
426/// [`crate::workflow`] already uses for `.devflow/state-{NN}.json`.
427///
428/// No parent-directory creation step: `.planning/` necessarily exists by the
429/// time this runs, because the read above succeeded from inside it.
430fn write_atomic(path: &Path, contents: &str) -> Result<(), GsdConfigError> {
431    let tmp = path.with_extension("json.devflow-tmp");
432    std::fs::write(&tmp, contents)?;
433    std::fs::rename(&tmp, path)?;
434    Ok(())
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    /// This project's REAL config shape — every top-level key the operator
442    /// owns, in the order the file has them, plus `workflow.auto_advance` and
443    /// the nested integer `workflow.subagent_timeout`.
444    ///
445    /// The top-level order is deliberately NOT alphabetical (`workflow` comes
446    /// second, `git` third), because an alphabetical fixture would satisfy the
447    /// key-order assertion below even under a `BTreeMap`-backed round trip and
448    /// prove nothing about `preserve_order`.
449    ///
450    /// Written in `serde_json::to_string_pretty`'s exact rendering (two-space
451    /// indent, one array element per line) so the whole-file byte comparison
452    /// below can be an equality rather than a normalized diff.
453    const REAL_SHAPE: &str = r#"{
454  "commit_docs": true,
455  "workflow": {
456    "granularity": "medium",
457    "auto_mode": true,
458    "auto_advance": true,
459    "commit_docs": true,
460    "subagent_timeout": 300000,
461    "_auto_chain_active": false,
462    "nyquist_validation": true,
463    "tdd_mode": true
464  },
465  "git": {
466    "main": "main",
467    "develop": "develop"
468  },
469  "intel": {
470    "enabled": true
471  },
472  "review": {
473    "default_reviewers": [
474      "codex"
475    ]
476  },
477  "model_overrides": {
478    "gsd-executor": "inherit"
479  },
480  "mempalace": {
481    "enabled": true
482  }
483}
484"#;
485
486    /// Write `contents` as a project's `.planning/config.json` and hand back
487    /// the root (the temp dir is returned too, so it outlives the test body).
488    fn project(contents: &str) -> (tempfile::TempDir, PathBuf) {
489        let dir = tempfile::tempdir().unwrap();
490        let root = dir.path().to_path_buf();
491        std::fs::create_dir_all(root.join(".planning")).unwrap();
492        std::fs::write(config_path(&root), contents).unwrap();
493        (dir, root)
494    }
495
496    /// [`REAL_SHAPE`] with the one owned value set either way, so a fixture can
497    /// carry the leak without a second near-duplicate literal drifting from the
498    /// first.
499    fn real_shape(active: bool) -> String {
500        let replaced = REAL_SHAPE.replace(
501            "\"_auto_chain_active\": false",
502            &format!("\"_auto_chain_active\": {active}"),
503        );
504        assert!(
505            replaced.contains(&format!("\"_auto_chain_active\": {active}")),
506            "the fixture must actually carry the requested flag value"
507        );
508        replaced
509    }
510
511    /// Hermetic git invocation pinned to `root` (999.37) — never a bare
512    /// `Command::new(\"git\")`.
513    fn git(root: &Path, args: &[&str]) {
514        let output = crate::git::git_command(root)
515            .args(args)
516            .output()
517            .expect("spawn git");
518        assert!(
519            output.status.success(),
520            "git {args:?} failed: {}",
521            String::from_utf8_lossy(&output.stderr)
522        );
523    }
524
525    fn git_output(root: &Path, args: &[&str]) -> String {
526        let output = crate::git::git_command(root)
527            .args(args)
528            .output()
529            .expect("spawn git");
530        assert!(
531            output.status.success(),
532            "git {args:?} failed: {}",
533            String::from_utf8_lossy(&output.stderr)
534        );
535        String::from_utf8_lossy(&output.stdout).into_owned()
536    }
537
538    /// A real temp repository with one commit, so `HEAD` exists — every probe
539    /// in `force_clear_auto_chain` is expressed against `HEAD`, and a repo with
540    /// no commits would exercise the could-not-certify arm instead of the arm
541    /// under test.
542    fn git_project() -> (tempfile::TempDir, PathBuf) {
543        let dir = tempfile::tempdir().unwrap();
544        let root = dir.path().canonicalize().unwrap();
545        git(&root, &["init", "-q"]);
546        git(&root, &["config", "user.email", "devflow@example.com"]);
547        git(&root, &["config", "user.name", "DevFlow Tests"]);
548        git(&root, &["config", "commit.gpgsign", "false"]);
549        git(&root, &["config", "core.hooksPath", "/dev/null"]);
550        std::fs::write(root.join("README.md"), "base\n").unwrap();
551        git(&root, &["add", "README.md"]);
552        git(&root, &["commit", "-q", "-m", "base"]);
553        std::fs::create_dir_all(root.join(".planning")).unwrap();
554        (dir, root)
555    }
556
557    fn write_config(root: &Path, contents: &str) {
558        std::fs::write(config_path(root), contents).unwrap();
559    }
560
561    fn commit_config(root: &Path, message: &str) {
562        git(root, &["add", CONFIG_PATHSPEC]);
563        git(root, &["commit", "-q", "-m", message]);
564    }
565
566    fn head_sha(root: &Path) -> String {
567        git_output(root, &["rev-parse", "HEAD"])
568    }
569
570    /// The flag as the BRANCH TIP holds it — read out of git, never out of the
571    /// working tree. A working-tree read cannot tell "committed the fix" from
572    /// "wrote the fix and forgot to commit", which is the exact gap the
573    /// committed half of this repair exists to close.
574    fn flag_at_head(root: &Path) -> Value {
575        let raw = git_output(root, &["show", &format!("HEAD:{CONFIG_PATHSPEC}")]);
576        let value: Value = serde_json::from_str(&raw).unwrap();
577        // Indexing, not `.get()`: an absent key must raise here rather than
578        // quietly render as `false` and agree with a correct cleared result.
579        value["workflow"]["_auto_chain_active"].clone()
580    }
581
582    /// The common case a killed monitor leaves behind: the leak is on disk but
583    /// never reached a commit. The working tree is repaired; the branch tip is
584    /// already in agreement afterwards, so nothing is committed.
585    #[test]
586    fn force_clear_repairs_a_leaked_working_tree_value() {
587        let (_dir, root) = git_project();
588        write_config(&root, &real_shape(false));
589        commit_config(&root, "add gsd config");
590        let head_before = head_sha(&root);
591        // The leak: written into the working tree after the commit, exactly as
592        // a SIGKILLed monitor leaves it.
593        write_config(&root, &real_shape(true));
594
595        let outcome = force_clear_auto_chain(&root).unwrap();
596
597        assert!(
598            outcome.working_tree_repaired,
599            "a set flag on disk must be reported as a working-tree repair"
600        );
601        assert!(
602            !outcome.committed_tree_repaired,
603            "the branch tip never carried the leak, so nothing may be committed"
604        );
605        assert_eq!(outcome.commit_refused, None);
606        assert!(
607            !auto_chain_active(&root).unwrap(),
608            "a subsequent read must see the cleared value"
609        );
610        assert_eq!(
611            head_sha(&root),
612            head_before,
613            "a working-tree-only repair must not add a commit"
614        );
615    }
616
617    /// Criterion 2's committed half: when the leak reached `HEAD`, the value
618    /// Ship would merge into `develop` is the CLEARED one. Read back out of git
619    /// rather than out of the working tree, for the reason [`flag_at_head`]
620    /// gives.
621    #[test]
622    fn force_clear_commits_when_the_leak_reached_head() {
623        let (_dir, root) = git_project();
624        write_config(&root, &real_shape(true));
625        commit_config(&root, "add gsd config carrying the leak");
626        assert_eq!(
627            flag_at_head(&root),
628            Value::Bool(true),
629            "the fixture must actually commit the leak, or the assertions below \
630             are vacuous"
631        );
632
633        let outcome = force_clear_auto_chain(&root).unwrap();
634
635        assert!(outcome.working_tree_repaired);
636        assert!(
637            outcome.committed_tree_repaired,
638            "a leak that reached HEAD must be repaired in the commit too, not \
639             only in the working tree — otherwise the branch → merge → develop \
640             → next-phase-chains path stays open (35.1 D-01)"
641        );
642        assert_eq!(outcome.commit_refused, None);
643        assert_eq!(flag_at_head(&root), Value::Bool(false));
644    }
645
646    /// F-8 / T-35.1-08: `commit_path` is path-scoped but still commits whatever
647    /// else is dirty IN that path. This repository has already had an incident
648    /// where an in-progress file was swept into an unrelated commit
649    /// (`CLAUDE.md`), and the correct posture is to refuse rather than sweep.
650    ///
651    /// **The `HEAD` comparison is the load-bearing assertion.** Asserting only
652    /// on the returned `commit_refused` would pass against an implementation
653    /// that committed the operator's edit and then reported a refusal.
654    #[test]
655    fn force_clear_refuses_to_commit_when_the_file_carries_other_changes() {
656        let (_dir, root) = git_project();
657        write_config(&root, &real_shape(true));
658        commit_config(&root, "add gsd config carrying the leak");
659        let head_before = head_sha(&root);
660        // An operator edit in flight, in the same file, beyond the one key
661        // DevFlow owns.
662        write_config(
663            &root,
664            &real_shape(true).replace("\"granularity\": \"medium\"", "\"granularity\": \"large\""),
665        );
666
667        let outcome = force_clear_auto_chain(&root).unwrap();
668
669        assert!(
670            outcome.working_tree_repaired,
671            "the working-tree clear disarms the bypass for THIS run and must \
672             happen even when the commit is declined"
673        );
674        assert!(
675            !outcome.committed_tree_repaired,
676            "the branch-tip repair must be deferred, not attempted"
677        );
678        let reason = outcome
679            .commit_refused
680            .expect("a declined commit must say why, loudly");
681        assert!(
682            reason.contains("beyond"),
683            "the refusal must name the cause — got: {reason}"
684        );
685        assert!(
686            !auto_chain_active(&root).unwrap(),
687            "the working tree is still cleared"
688        );
689        assert!(
690            std::fs::read_to_string(config_path(&root))
691                .unwrap()
692                .contains("\"granularity\": \"large\""),
693            "the operator's in-flight edit must survive untouched"
694        );
695        assert_eq!(
696            head_sha(&root),
697            head_before,
698            "nothing may be committed — this assertion, not the returned \
699             refusal, is what distinguishes a genuine refusal from a commit \
700             that reported one"
701        );
702    }
703
704    /// The no-op control. Without it, an implementation that always reported a
705    /// repair — or always committed — would satisfy every test above.
706    #[test]
707    fn force_clear_on_an_already_clean_config_reports_nothing_and_writes_nothing() {
708        let (_dir, root) = git_project();
709        write_config(&root, &real_shape(false));
710        commit_config(&root, "add a clean gsd config");
711        let head_before = head_sha(&root);
712        let bytes_before = std::fs::read(config_path(&root)).unwrap();
713
714        let outcome = force_clear_auto_chain(&root).unwrap();
715
716        assert!(!outcome.working_tree_repaired);
717        assert!(!outcome.committed_tree_repaired);
718        assert_eq!(outcome.commit_refused, None);
719        assert!(
720            !outcome.repaired_anything(),
721            "an ordinary clean launch must have nothing to be loud about"
722        );
723        assert_eq!(std::fs::read(config_path(&root)).unwrap(), bytes_before);
724        assert_eq!(head_sha(&root), head_before);
725    }
726
727    /// A project that never had GSD config has nothing to leak. A hard error
728    /// here would break `devflow start` for every non-GSD project, which is a
729    /// far worse failure than the one this repair prevents.
730    #[test]
731    fn force_clear_on_a_project_without_a_gsd_config_is_a_clean_no_op() {
732        let dir = tempfile::tempdir().unwrap();
733        let root = dir.path().to_path_buf();
734
735        let outcome = force_clear_auto_chain(&root).expect("an absent config is not an error");
736
737        assert!(!outcome.working_tree_repaired);
738        assert!(!outcome.committed_tree_repaired);
739        assert_eq!(outcome.commit_refused, None);
740    }
741
742    /// Absent and malformed are different facts and get different answers: a
743    /// file that cannot be parsed cannot be certified clear, so it propagates
744    /// rather than silently reading as a clean no-op.
745    #[test]
746    fn force_clear_on_a_malformed_config_is_an_error() {
747        let (_dir, root) = git_project();
748        let malformed = "{ \"workflow\": { \"_auto_chain_active\": tru";
749        write_config(&root, malformed);
750
751        assert!(matches!(
752            force_clear_auto_chain(&root),
753            Err(GsdConfigError::Json(_))
754        ));
755        assert_eq!(
756            std::fs::read_to_string(config_path(&root)).unwrap(),
757            malformed,
758            "a failed certification must leave the operator's file exactly as \
759             it was"
760        );
761    }
762
763    /// A project whose `.planning/` exists but holds no config file.
764    fn empty_project() -> (tempfile::TempDir, PathBuf) {
765        let dir = tempfile::tempdir().unwrap();
766        let root = dir.path().to_path_buf();
767        std::fs::create_dir_all(root.join(".planning")).unwrap();
768        (dir, root)
769    }
770
771    /// Everything except the one key this module owns must survive a write
772    /// unchanged — value, position, and serialized form.
773    ///
774    /// **The assertion is on the file's BYTES, not on a re-parse.** An earlier
775    /// version of this test compared `keys()` from two `serde_json::Value`
776    /// parses and passed with `preserve_order` REMOVED — because without that
777    /// feature both parses go through a `BTreeMap`, so both key lists come out
778    /// alphabetized and agree with each other. The comparison normalized away
779    /// the exact property it claimed to measure. Comparing raw text is what
780    /// makes this discriminating, and it subsumes numeric re-rendering
781    /// (`subagent_timeout: 300000`) as well as ordering.
782    #[test]
783    fn writing_the_flag_leaves_every_other_key_byte_identical() {
784        let (_dir, root) = project(REAL_SHAPE);
785        let before = std::fs::read_to_string(config_path(&root)).unwrap();
786
787        assert!(set_auto_chain_active(&root, true).unwrap());
788
789        let after = std::fs::read_to_string(config_path(&root)).unwrap();
790        let expected = before.replace(
791            "\"_auto_chain_active\": false",
792            "\"_auto_chain_active\": true",
793        );
794        assert_ne!(
795            expected, before,
796            "the fixture must actually contain the key this test flips, or the \
797             comparison below is vacuous"
798        );
799        assert_eq!(
800            after, expected,
801            "the written file must differ from the original in EXACTLY the one \
802             value this module owns — same key order, same number rendering, \
803             same whitespace"
804        );
805
806        // And the flag really did flip, read back through this module's own
807        // accessor rather than by re-reading the text just compared.
808        assert!(auto_chain_active(&root).unwrap());
809    }
810
811    /// Criterion 3b / D-06: this phase buys checkpoint APPROVAL, not workflow
812    /// chaining. Nothing in this codebase may write `workflow.auto_advance`.
813    ///
814    /// Why it matters concretely: GSD's `check auto-mode` ORs the two flags
815    /// (`check-command-router.cjs:107`), so a write that clobbered
816    /// `auto_advance` would silently enable the stage-chaining ROADMAP
817    /// criterion 3 forbids — and it would do so through a key DevFlow never
818    /// intended to touch.
819    #[test]
820    fn writing_the_flag_never_touches_auto_advance() {
821        let (_dir, root) = project(REAL_SHAPE);
822        // The fixture has `auto_advance: true` BEFORE the call, deliberately.
823        // A fixture where it were `false` would prove nothing: `false` is also
824        // what a dropped key deserializes to.
825        assert_eq!(
826            serde_json::from_str::<Value>(REAL_SHAPE).unwrap()["workflow"]["auto_advance"],
827            Value::Bool(true)
828        );
829
830        set_auto_chain_active(&root, true).unwrap();
831        set_auto_chain_active(&root, false).unwrap();
832
833        let after: Value =
834            serde_json::from_str(&std::fs::read_to_string(config_path(&root)).unwrap()).unwrap();
835        assert_eq!(
836            after["workflow"]["auto_advance"],
837            Value::Bool(true),
838            "auto_advance is the operator's, and neither setting nor clearing \
839             the chain flag may disturb it"
840        );
841    }
842
843    /// F-3: the ineligible-launch path asserts `false` on every stage launch,
844    /// so writing a value the file already holds must cost nothing — otherwise
845    /// a tracked file is rewritten on every run of every stage.
846    #[test]
847    fn setting_the_value_it_already_holds_is_a_no_op() {
848        let (_dir, root) = project(REAL_SHAPE);
849        let path = config_path(&root);
850        let before = std::fs::read(&path).unwrap();
851        let before_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
852
853        // REAL_SHAPE holds `false`; ask for `false`.
854        assert!(
855            !set_auto_chain_active(&root, false).unwrap(),
856            "a write that changes nothing must report that it changed nothing"
857        );
858
859        assert_eq!(before, std::fs::read(&path).unwrap());
860        assert_eq!(
861            before_mtime,
862            std::fs::metadata(&path).unwrap().modified().unwrap()
863        );
864
865        // Negative control: the same call with the OTHER value must report a
866        // change. Without this, a `set_auto_chain_active` that always returned
867        // `false` and never wrote would satisfy the assertions above.
868        assert!(set_auto_chain_active(&root, true).unwrap());
869    }
870
871    /// A config that simply has not grown a `workflow` object yet is a normal
872    /// shape, not a corrupt one.
873    #[test]
874    fn a_missing_workflow_object_is_created_rather_than_rejected() {
875        let (_dir, root) = project("{\n  \"commit_docs\": true\n}\n");
876
877        assert!(set_auto_chain_active(&root, true).unwrap());
878        assert!(auto_chain_active(&root).unwrap());
879
880        let after: Value =
881            serde_json::from_str(&std::fs::read_to_string(config_path(&root)).unwrap()).unwrap();
882        assert_eq!(
883            after["commit_docs"],
884            Value::Bool(true),
885            "creating the workflow object must not disturb the keys already there"
886        );
887    }
888
889    /// ASVS V5: a hand-edited config is untrusted input. It must produce an
890    /// `Err` the caller can log and skip, never a panic that would kill a long
891    /// unattended run — and the atomic write must not have truncated anything
892    /// on the way to failing.
893    #[test]
894    fn a_malformed_config_is_an_error_not_a_panic() {
895        let malformed = "{ \"workflow\": { \"auto_advance\": tru";
896        let (_dir, root) = project(malformed);
897
898        // Asserted on the returned Err, never on a caught panic.
899        assert!(matches!(
900            set_auto_chain_active(&root, true),
901            Err(GsdConfigError::Json(_))
902        ));
903        assert!(matches!(
904            auto_chain_active(&root),
905            Err(GsdConfigError::Json(_))
906        ));
907        assert_eq!(
908            std::fs::read_to_string(config_path(&root)).unwrap(),
909            malformed,
910            "a failed write must leave the operator's file exactly as it was"
911        );
912    }
913
914    /// A JSON document whose root is not an object has nowhere for the key to
915    /// live. Refuse rather than replace the operator's file wholesale.
916    #[test]
917    fn a_non_object_config_root_is_an_error_not_a_replacement() {
918        let (_dir, root) = project("[1, 2, 3]\n");
919
920        assert!(matches!(
921            set_auto_chain_active(&root, true),
922            Err(GsdConfigError::Json(_))
923        ));
924        assert_eq!(
925            std::fs::read_to_string(config_path(&root)).unwrap(),
926            "[1, 2, 3]\n"
927        );
928    }
929
930    /// No file at all is an explicit `Err`, not a silent create — DevFlow does
931    /// not own this file and must not conjure one into a project that has no
932    /// GSD config.
933    #[test]
934    fn an_absent_config_is_an_error_not_a_panic() {
935        let (_dir, root) = empty_project();
936
937        assert!(matches!(
938            set_auto_chain_active(&root, true),
939            Err(GsdConfigError::Missing(_))
940        ));
941        assert!(matches!(
942            auto_chain_active(&root),
943            Err(GsdConfigError::Missing(_))
944        ));
945        assert!(
946            !config_path(&root).exists(),
947            "a failed write must not leave a file behind"
948        );
949    }
950
951    /// The V5 defensive-default row: three shapes this module does not model,
952    /// all of which must read as the INACTIVE value rather than panic.
953    ///
954    /// Reading via `value["workflow"]["_auto_chain_active"]` would panic on the
955    /// first of these; that is exactly the indexing this module forbids on a
956    /// read path.
957    #[test]
958    fn reading_the_flag_defaults_to_the_inactive_value_on_a_shape_it_does_not_recognise() {
959        for shape in [
960            // `workflow` absent entirely.
961            "{ \"commit_docs\": true }",
962            // `workflow` present but not an object.
963            "{ \"workflow\": \"medium\" }",
964            // the key present but not a boolean.
965            "{ \"workflow\": { \"_auto_chain_active\": \"true\" } }",
966        ] {
967            let (_dir, root) = project(shape);
968            assert!(
969                !auto_chain_active(&root).unwrap(),
970                "unrecognised shape must read inactive, not panic: {shape}"
971            );
972        }
973
974        // Negative control: the shape this module DOES recognise still reads
975        // active, so the three assertions above are discriminating rather than
976        // a function that always returns `false`.
977        let (_dir, root) = project("{ \"workflow\": { \"_auto_chain_active\": true } }");
978        assert!(auto_chain_active(&root).unwrap());
979    }
980
981    /// The file's trailing-newline convention survives a write, so the diff
982    /// stays one line instead of gaining a spurious no-newline-at-EOF marker.
983    #[test]
984    fn the_trailing_newline_convention_survives_a_write() {
985        let (_dir, root) = project(REAL_SHAPE);
986        set_auto_chain_active(&root, true).unwrap();
987        assert!(
988            std::fs::read_to_string(config_path(&root))
989                .unwrap()
990                .ends_with('\n')
991        );
992
993        let without = REAL_SHAPE.trim_end_matches('\n').to_string();
994        let (_dir2, root2) = project(&without);
995        set_auto_chain_active(&root2, true).unwrap();
996        assert!(
997            !std::fs::read_to_string(config_path(&root2))
998                .unwrap()
999                .ends_with('\n')
1000        );
1001    }
1002
1003    /// The write leaves no temporary file behind for a `git add` to sweep up.
1004    #[test]
1005    fn the_atomic_write_leaves_no_temp_file_behind() {
1006        let (_dir, root) = project(REAL_SHAPE);
1007        set_auto_chain_active(&root, true).unwrap();
1008
1009        let leftovers: Vec<_> = std::fs::read_dir(root.join(".planning"))
1010            .unwrap()
1011            .filter_map(Result::ok)
1012            .map(|entry| entry.file_name().to_string_lossy().into_owned())
1013            .filter(|name| name != "config.json")
1014            .collect();
1015        assert!(
1016            leftovers.is_empty(),
1017            "stray files in .planning: {leftovers:?}"
1018        );
1019    }
1020}