devflow-core 2.7.0

Opinionated AI-driven development workflow state machine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
//! The single writer of GSD's `.planning/config.json` in this codebase.
//!
//! **DevFlow does not own this file.** Its other keys belong to GSD and to the
//! operator, and a live GSD process may read it at any moment during a run.
//! This module therefore acquires exactly ONE key of write authority —
//! `workflow._auto_chain_active`, the flag GSD's `checkpoint_handling` consults
//! to decide whether an ordinary `gate="blocking"` checkpoint may be
//! auto-approved — and must leave every other key, its position, and its
//! serialized form byte-identical (35.1-01, T-35.1-02).
//!
//! Three properties follow from that, and each is pinned by a test rather than
//! left to inspection:
//!
//! 1. **No typed struct.** The file is parsed as a bare [`serde_json::Value`].
//!    A typed `Config`/`Workflow` round trip would silently DROP the keys this
//!    crate does not model (`commit_docs`, `git`, `intel`, `review`,
//!    `model_overrides`, `mempalace`), which is precisely the contract this
//!    module exists to honour.
//! 2. **Key order is preserved.** `serde_json`'s `preserve_order` feature is
//!    enabled in the workspace `Cargo.toml` for this module's sake; without it
//!    every write re-sorts the top-level keys alphabetically and turns a
//!    tracked file into a full-file diff on every stage launch.
//! 3. **Writing a value the file already holds is a no-op.** The file's bytes
//!    and mtime are left untouched, so an INELIGIBLE stage launch — which
//!    actively asserts `false` rather than merely leaving the file alone — does
//!    not dirty a tracked file every time it runs (35.1-01 F-3).
//!
//! Reads are defensive (ASVS V5): a shape this module does not recognise
//! yields the inactive default, never a panic, and a malformed or absent file
//! is an `Err` the caller can log and skip rather than an abort that would kill
//! a long unattended run.

use crate::git::GitFlow;
use serde_json::Value;
use std::path::{Path, PathBuf};

/// The key this module owns, and the only one it may write.
const AUTO_CHAIN_KEY: &str = "_auto_chain_active";
/// The object that key lives under.
const WORKFLOW_KEY: &str = "workflow";

/// Errors produced while reading or writing GSD's project config.
///
/// Shape follows [`crate::workflow::WorkflowError`] — `Io`/`Json` `#[from]`
/// variants so every call site converts with `?`, plus a named variant for the
/// "there is no file at all" case so the message says which path was missing
/// instead of surfacing a bare `NotFound`.
#[derive(Debug, thiserror::Error)]
pub enum GsdConfigError {
    /// Filesystem operation failed.
    #[error("GSD config I/O failed: {0}")]
    Io(#[from] std::io::Error),
    /// JSON parse or serialization failed.
    #[error("GSD config JSON failed: {0}")]
    Json(#[from] serde_json::Error),
    /// No GSD config exists at the expected path.
    #[error("no GSD config at {0}")]
    Missing(PathBuf),
}

/// Path of the GSD project config under a project (or worktree) root.
///
/// `root` is the directory whose `.planning/` is the tracked, committed one —
/// in worktree mode that is the WORKTREE, not the main checkout, because the
/// worktree copy is the one the agent's `check auto-mode` actually reads.
#[must_use]
pub fn config_path(root: &Path) -> PathBuf {
    root.join(".planning").join("config.json")
}

/// Read the config file into a [`Value`], distinguishing "absent" from
/// "malformed" so a caller can tell a project that never had GSD config from
/// one whose config it must not touch.
fn read_config(path: &Path) -> Result<Value, GsdConfigError> {
    let contents = match std::fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return Err(GsdConfigError::Missing(path.to_path_buf()));
        }
        Err(err) => return Err(GsdConfigError::Io(err)),
    };
    Ok(serde_json::from_str(&contents)?)
}

/// Whether `workflow._auto_chain_active` is currently set under `root`.
///
/// Deliberately mirrors GSD's own defensive-default idiom
/// (`check-command-router.cjs:95-111`): any shape this module does not
/// recognise — no `workflow` object, a `workflow` that is not an object, a
/// non-boolean value — reads as the inactive default. **Never index**; indexing
/// a missing key panics, and a panic here would kill an unattended run over a
/// hand-edited config file (ASVS V5).
///
/// # Errors
///
/// Returns [`GsdConfigError::Missing`] when no config file exists and
/// [`GsdConfigError::Json`] when the file is not valid JSON. A file that parses
/// but lacks the key is NOT an error — that is the defensive default above.
pub fn auto_chain_active(root: &Path) -> Result<bool, GsdConfigError> {
    let value = read_config(&config_path(root))?;
    Ok(read_flag(&value))
}

/// The defensive read, factored out so the write path uses the exact same
/// interpretation it will later be asserted against.
fn read_flag(value: &Value) -> bool {
    value
        .get(WORKFLOW_KEY)
        .and_then(|workflow| workflow.get(AUTO_CHAIN_KEY))
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

/// Set `workflow._auto_chain_active` under `root`, returning whether the file
/// was actually changed.
///
/// Writing a value the file already holds is a genuine no-op: `Ok(false)` is
/// returned and the file's bytes and mtime are untouched (F-3). That is what
/// keeps the symmetric guard — which asserts `false` on every ineligible launch
/// rather than leaving whatever it finds — from rewriting a tracked file on
/// every stage of every run.
///
/// A missing `workflow` object is CREATED rather than rejected; a config that
/// simply has not grown that key yet is a normal shape, not a corrupt one.
///
/// # Errors
///
/// Returns [`GsdConfigError::Missing`] when no config file exists,
/// [`GsdConfigError::Json`] when the existing file is not valid JSON, and
/// [`GsdConfigError::Io`] when the atomic write fails. In every error case the
/// original file is left exactly as it was — the temp-write-then-`rename`
/// idiom means a failure never leaves a truncated config behind.
pub fn set_auto_chain_active(root: &Path, active: bool) -> Result<bool, GsdConfigError> {
    let path = config_path(root);
    let raw = match std::fs::read_to_string(&path) {
        Ok(raw) => raw,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return Err(GsdConfigError::Missing(path));
        }
        Err(err) => return Err(GsdConfigError::Io(err)),
    };
    let mut value: Value = serde_json::from_str(&raw)?;

    if read_flag(&value) == active {
        return Ok(false);
    }

    // Insert the `workflow` object when it is absent OR is not an object at
    // all. Replacing a non-object `workflow` is the only case in which this
    // module overwrites something it does not own, and it is unavoidable: there
    // is nowhere else the key can live, and leaving it would mean silently
    // reporting success while writing nothing.
    if !value.get(WORKFLOW_KEY).is_some_and(Value::is_object) {
        if let Some(map) = value.as_object_mut() {
            map.insert(
                WORKFLOW_KEY.to_string(),
                Value::Object(serde_json::Map::new()),
            );
        } else {
            // The document's root is not an object — there is no `workflow`
            // key to set and never was. Treat it as malformed rather than
            // replacing the operator's file wholesale.
            return Err(GsdConfigError::Json(serde::de::Error::custom(
                "GSD config root is not a JSON object",
            )));
        }
    }
    value[WORKFLOW_KEY][AUTO_CHAIN_KEY] = Value::Bool(active);

    let mut contents = serde_json::to_string_pretty(&value)?;
    // `to_string_pretty` emits no trailing newline; the operator's file has
    // one. Preserving whatever the file already used keeps the diff to the one
    // line this module owns.
    if raw.ends_with('\n') {
        contents.push('\n');
    }
    write_atomic(&path, &contents)?;
    Ok(true)
}

/// What [`force_clear_auto_chain`] actually did, so the CLI call sites can
/// decide whether to be loud without re-deriving any of it.
///
/// Three independent facts, deliberately not collapsed into one enum: a repair
/// can touch the working tree only, the working tree AND the branch tip, or the
/// working tree while explicitly DECLINING the branch tip. The third is not a
/// failure — it is the correct answer when committing would sweep in an edit
/// DevFlow does not own (F-8) — and a call site that could not tell it apart
/// from "nothing happened" would report the deferral as silence.
#[derive(Debug, Default)]
pub struct ClearOutcome {
    /// The file on disk carried a set flag and now does not.
    pub working_tree_repaired: bool,
    /// The branch tip carried a set flag and a commit was made so it no longer
    /// does. Confirmed by re-probing the tip after the commit, never inferred
    /// from `commit_path` returning `Ok`.
    pub committed_tree_repaired: bool,
    /// Why the branch-tip half was NOT attempted or did not land. Populated
    /// whenever the working tree was corrected but the committed copy was left
    /// alone for a reason the operator needs to hear.
    pub commit_refused: Option<String>,
}

impl ClearOutcome {
    /// Whether either half of the repair actually changed something.
    ///
    /// Deliberately excludes `commit_refused`: a refusal is a separate thing to
    /// be loud about, and folding it in here would make "we fixed something"
    /// and "we declined to fix something" indistinguishable at the call site.
    #[must_use]
    pub fn repaired_anything(&self) -> bool {
        self.working_tree_repaired || self.committed_tree_repaired
    }
}

/// The config's path as a git pathspec — relative, because that is what
/// `commit_path` and `git show HEAD:<path>` both need.
const CONFIG_PATHSPEC: &str = ".planning/config.json";

/// Clear `workflow._auto_chain_active` under `root` unconditionally, repairing
/// the branch tip too when that can be done without sweeping in anything else.
///
/// **This is the second, independent mechanism (35.1 D-01).** The in-process
/// [`crate::gsd_config`] guard held by `devflow`'s monitor covers a normal
/// return, a `?` early-return and a panic-unwind — and structurally cannot
/// cover a `SIGKILL`, because `Drop` never runs. A killed monitor therefore
/// leaves a set flag in a TRACKED file, from where `commit_docs` or any
/// sweeping `git add` can carry it onto `develop` and into the next phase's
/// `plan-phase` invocation, where the same boolean no longer means "approve
/// this checkpoint" but "chain into execute-phase". Rather than try to make
/// the guard cover the uncoverable, both launch entry points repair forward.
///
/// Emits nothing and prints nothing: this is `devflow-core` and it returns a
/// report. The operator-facing notice and the `events.jsonl` entry belong to
/// the CLI call sites, which is what lets this function's tests assert on an
/// outcome value rather than on captured stdout.
///
/// # Errors
///
/// Returns [`GsdConfigError::Json`] when the config exists but cannot be
/// parsed — a file DevFlow cannot read cannot be certified clear, so the caller
/// must hear about it. An ABSENT config is not an error: a project with no GSD
/// config has nothing to leak, and failing here would break `devflow start` for
/// every non-GSD project.
pub fn force_clear_auto_chain(root: &Path) -> Result<ClearOutcome, GsdConfigError> {
    let mut outcome = ClearOutcome::default();

    match set_auto_chain_active(root, false) {
        Ok(changed) => outcome.working_tree_repaired = changed,
        // Absent and malformed are different facts. A project with no GSD
        // config has nothing to leak; a config that cannot be parsed cannot be
        // certified clean and must reach the caller.
        Err(GsdConfigError::Missing(_)) => return Ok(outcome),
        Err(err) => return Err(err),
    }

    // F-7: ask the branch tip whether it still disagrees with the corrected
    // file, in one call, rather than parsing HEAD's copy a second time. This
    // answers correctly in BOTH directions that matter — a working tree that
    // was already clear but a HEAD that carries the leak still shows a
    // difference and still gets repaired.
    match probe_head(root) {
        HeadProbe::Agrees => return Ok(outcome),
        HeadProbe::Differs => {}
        HeadProbe::Unknown(reason) => {
            // Unreachable is not absent. A probe that could not run tells us
            // nothing about the tip, and assuming agreement from a failed
            // measurement is exactly the class of error this project runs on
            // negative controls to avoid.
            outcome.commit_refused = Some(reason);
            return Ok(outcome);
        }
    }

    // F-8 / T-35.1-08: `commit_path` is path-scoped but still commits whatever
    // else is dirty IN that path. Compare HEAD's copy against the corrected
    // working copy with the one key DevFlow owns removed from both; if the
    // remainder differs, the file carries an edit DevFlow was not asked to
    // touch. Refuse — the working-tree clear already disarmed the bypass for
    // THIS run, so all that is deferred is the branch-tip half, and deferring
    // it visibly is strictly better than committing an operator's unfinished
    // work.
    let head_text = match head_copy(root) {
        Ok(text) => text,
        Err(reason) => {
            outcome.commit_refused = Some(reason);
            return Ok(outcome);
        }
    };
    let working_text = std::fs::read_to_string(config_path(root))?;
    match (
        serde_json::from_str::<Value>(&head_text),
        serde_json::from_str::<Value>(&working_text),
    ) {
        (Ok(mut head), Ok(mut working)) => {
            without_flag(&mut head);
            without_flag(&mut working);
            if head != working {
                outcome.commit_refused = Some(format!(
                    "{CONFIG_PATHSPEC} carries changes beyond the chain flag — the \
                     branch-tip repair was deferred rather than sweep an unrelated \
                     edit into a DevFlow commit"
                ));
                return Ok(outcome);
            }
        }
        _ => {
            outcome.commit_refused = Some(format!(
                "could not parse both copies of {CONFIG_PATHSPEC} — the branch-tip \
                 repair was deferred rather than committed unverified"
            ));
            return Ok(outcome);
        }
    }

    // F-9: a commit failure is a loud warning, not a run-killer. Hooks, a
    // detached HEAD, an unexpected git state — any of these can make this fail,
    // and the working tree is already repaired, so the bypass is already off.
    // Do not "harden" this into a `?`.
    if let Err(err) = GitFlow::new(root).commit_path(CONFIG_PATHSPEC, REPAIR_COMMIT_MESSAGE) {
        outcome.commit_refused = Some(format!(
            "the branch-tip repair could not be committed ({err}) — the working \
             tree is corrected, but this branch still carries the leaked value"
        ));
        return Ok(outcome);
    }

    // Confirm rather than assume. `commit_path` converts git's "nothing to
    // commit" into `Ok(())`, so a bare `Ok` is not evidence that the tip moved;
    // re-probing is what makes `committed_tree_repaired` a measurement.
    match probe_head(root) {
        HeadProbe::Agrees => outcome.committed_tree_repaired = true,
        HeadProbe::Differs => {
            outcome.commit_refused = Some(format!(
                "the branch-tip repair reported success but {CONFIG_PATHSPEC} still \
                 disagrees with HEAD — this branch may still carry the leaked value"
            ));
        }
        HeadProbe::Unknown(reason) => outcome.commit_refused = Some(reason),
    }
    Ok(outcome)
}

/// The commit the repair writes on the operator's behalf. A Conventional
/// Commit subject under 72 characters, and a body that says what a stale flag
/// MEANS rather than merely that a value changed.
const REPAIR_COMMIT_MESSAGE: &str = "\
fix(gsd): clear a leaked auto-chain flag before launch

A previous run for this phase was killed before its in-process guard could
clear workflow._auto_chain_active, and the leaked value reached this branch.
Left in place it travels through Ship into develop, where a later phase's
plan-phase invocation reads the same boolean as \"chain into execute-phase\"
rather than \"approve this checkpoint\".

Repaired forward by devflow start/resume (35.1 D-01).";

/// What the branch tip says about the corrected file. Three answers, not two:
/// a probe that could not run is its own case and must never collapse into
/// "agrees".
enum HeadProbe {
    /// The tip already matches the corrected working copy.
    Agrees,
    /// The tip still differs — there is a branch-tip repair to make.
    Differs,
    /// The question could not be answered; the reason is operator-facing.
    Unknown(String),
}

/// `git diff --quiet HEAD -- .planning/config.json`, read by exit code: 0 means
/// no difference, 1 means a difference, and anything else (128 for "not a
/// repository", "unknown revision", and friends) means the probe itself failed.
fn probe_head(root: &Path) -> HeadProbe {
    match crate::git::git_command(root)
        .args(["diff", "--quiet", "HEAD", "--", CONFIG_PATHSPEC])
        .output()
    {
        Ok(output) => match output.status.code() {
            Some(0) => HeadProbe::Agrees,
            Some(1) => HeadProbe::Differs,
            other => HeadProbe::Unknown(format!(
                "could not compare {CONFIG_PATHSPEC} against HEAD (git exited \
                 {other:?}) — the branch-tip repair was deferred rather than \
                 assumed unnecessary"
            )),
        },
        Err(err) => HeadProbe::Unknown(format!(
            "could not run git to compare {CONFIG_PATHSPEC} against HEAD ({err}) — \
             the branch-tip repair was deferred rather than assumed unnecessary"
        )),
    }
}

/// HEAD's copy of the config as text, or an operator-facing reason it could not
/// be read. A tip that holds no readable copy is a refusal, not a licence to
/// commit blind.
fn head_copy(root: &Path) -> Result<String, String> {
    match crate::git::git_command(root)
        .args(["show", &format!("HEAD:{CONFIG_PATHSPEC}")])
        .output()
    {
        Ok(output) if output.status.success() => {
            Ok(String::from_utf8_lossy(&output.stdout).into_owned())
        }
        Ok(_) => Err(format!(
            "HEAD holds no readable {CONFIG_PATHSPEC} to compare against — the \
             branch-tip repair was deferred rather than committed unverified"
        )),
        Err(err) => Err(format!(
            "could not read HEAD's copy of {CONFIG_PATHSPEC} ({err}) — the \
             branch-tip repair was deferred rather than committed unverified"
        )),
    }
}

/// Remove the one key DevFlow owns, so what remains is exactly the part of the
/// file that belongs to GSD and the operator.
fn without_flag(value: &mut Value) {
    if let Some(workflow) = value.get_mut(WORKFLOW_KEY).and_then(Value::as_object_mut) {
        workflow.remove(AUTO_CHAIN_KEY);
    }
}

/// Write through a sibling temporary file so a live GSD process never observes
/// a truncated or partially written config (T-35.1-04) — the same idiom
/// [`crate::workflow`] already uses for `.devflow/state-{NN}.json`.
///
/// No parent-directory creation step: `.planning/` necessarily exists by the
/// time this runs, because the read above succeeded from inside it.
fn write_atomic(path: &Path, contents: &str) -> Result<(), GsdConfigError> {
    let tmp = path.with_extension("json.devflow-tmp");
    std::fs::write(&tmp, contents)?;
    std::fs::rename(&tmp, path)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// This project's REAL config shape — every top-level key the operator
    /// owns, in the order the file has them, plus `workflow.auto_advance` and
    /// the nested integer `workflow.subagent_timeout`.
    ///
    /// The top-level order is deliberately NOT alphabetical (`workflow` comes
    /// second, `git` third), because an alphabetical fixture would satisfy the
    /// key-order assertion below even under a `BTreeMap`-backed round trip and
    /// prove nothing about `preserve_order`.
    ///
    /// Written in `serde_json::to_string_pretty`'s exact rendering (two-space
    /// indent, one array element per line) so the whole-file byte comparison
    /// below can be an equality rather than a normalized diff.
    const REAL_SHAPE: &str = r#"{
  "commit_docs": true,
  "workflow": {
    "granularity": "medium",
    "auto_mode": true,
    "auto_advance": true,
    "commit_docs": true,
    "subagent_timeout": 300000,
    "_auto_chain_active": false,
    "nyquist_validation": true,
    "tdd_mode": true
  },
  "git": {
    "main": "main",
    "develop": "develop"
  },
  "intel": {
    "enabled": true
  },
  "review": {
    "default_reviewers": [
      "codex"
    ]
  },
  "model_overrides": {
    "gsd-executor": "inherit"
  },
  "mempalace": {
    "enabled": true
  }
}
"#;

    /// Write `contents` as a project's `.planning/config.json` and hand back
    /// the root (the temp dir is returned too, so it outlives the test body).
    fn project(contents: &str) -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_path_buf();
        std::fs::create_dir_all(root.join(".planning")).unwrap();
        std::fs::write(config_path(&root), contents).unwrap();
        (dir, root)
    }

    /// [`REAL_SHAPE`] with the one owned value set either way, so a fixture can
    /// carry the leak without a second near-duplicate literal drifting from the
    /// first.
    fn real_shape(active: bool) -> String {
        let replaced = REAL_SHAPE.replace(
            "\"_auto_chain_active\": false",
            &format!("\"_auto_chain_active\": {active}"),
        );
        assert!(
            replaced.contains(&format!("\"_auto_chain_active\": {active}")),
            "the fixture must actually carry the requested flag value"
        );
        replaced
    }

    /// Hermetic git invocation pinned to `root` (999.37) — never a bare
    /// `Command::new(\"git\")`.
    fn git(root: &Path, args: &[&str]) {
        let output = crate::git::git_command(root)
            .args(args)
            .output()
            .expect("spawn git");
        assert!(
            output.status.success(),
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    fn git_output(root: &Path, args: &[&str]) -> String {
        let output = crate::git::git_command(root)
            .args(args)
            .output()
            .expect("spawn git");
        assert!(
            output.status.success(),
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8_lossy(&output.stdout).into_owned()
    }

    /// A real temp repository with one commit, so `HEAD` exists — every probe
    /// in `force_clear_auto_chain` is expressed against `HEAD`, and a repo with
    /// no commits would exercise the could-not-certify arm instead of the arm
    /// under test.
    fn git_project() -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().canonicalize().unwrap();
        git(&root, &["init", "-q"]);
        git(&root, &["config", "user.email", "devflow@example.com"]);
        git(&root, &["config", "user.name", "DevFlow Tests"]);
        git(&root, &["config", "commit.gpgsign", "false"]);
        git(&root, &["config", "core.hooksPath", "/dev/null"]);
        std::fs::write(root.join("README.md"), "base\n").unwrap();
        git(&root, &["add", "README.md"]);
        git(&root, &["commit", "-q", "-m", "base"]);
        std::fs::create_dir_all(root.join(".planning")).unwrap();
        (dir, root)
    }

    fn write_config(root: &Path, contents: &str) {
        std::fs::write(config_path(root), contents).unwrap();
    }

    fn commit_config(root: &Path, message: &str) {
        git(root, &["add", CONFIG_PATHSPEC]);
        git(root, &["commit", "-q", "-m", message]);
    }

    fn head_sha(root: &Path) -> String {
        git_output(root, &["rev-parse", "HEAD"])
    }

    /// The flag as the BRANCH TIP holds it — read out of git, never out of the
    /// working tree. A working-tree read cannot tell "committed the fix" from
    /// "wrote the fix and forgot to commit", which is the exact gap the
    /// committed half of this repair exists to close.
    fn flag_at_head(root: &Path) -> Value {
        let raw = git_output(root, &["show", &format!("HEAD:{CONFIG_PATHSPEC}")]);
        let value: Value = serde_json::from_str(&raw).unwrap();
        // Indexing, not `.get()`: an absent key must raise here rather than
        // quietly render as `false` and agree with a correct cleared result.
        value["workflow"]["_auto_chain_active"].clone()
    }

    /// The common case a killed monitor leaves behind: the leak is on disk but
    /// never reached a commit. The working tree is repaired; the branch tip is
    /// already in agreement afterwards, so nothing is committed.
    #[test]
    fn force_clear_repairs_a_leaked_working_tree_value() {
        let (_dir, root) = git_project();
        write_config(&root, &real_shape(false));
        commit_config(&root, "add gsd config");
        let head_before = head_sha(&root);
        // The leak: written into the working tree after the commit, exactly as
        // a SIGKILLed monitor leaves it.
        write_config(&root, &real_shape(true));

        let outcome = force_clear_auto_chain(&root).unwrap();

        assert!(
            outcome.working_tree_repaired,
            "a set flag on disk must be reported as a working-tree repair"
        );
        assert!(
            !outcome.committed_tree_repaired,
            "the branch tip never carried the leak, so nothing may be committed"
        );
        assert_eq!(outcome.commit_refused, None);
        assert!(
            !auto_chain_active(&root).unwrap(),
            "a subsequent read must see the cleared value"
        );
        assert_eq!(
            head_sha(&root),
            head_before,
            "a working-tree-only repair must not add a commit"
        );
    }

    /// Criterion 2's committed half: when the leak reached `HEAD`, the value
    /// Ship would merge into `develop` is the CLEARED one. Read back out of git
    /// rather than out of the working tree, for the reason [`flag_at_head`]
    /// gives.
    #[test]
    fn force_clear_commits_when_the_leak_reached_head() {
        let (_dir, root) = git_project();
        write_config(&root, &real_shape(true));
        commit_config(&root, "add gsd config carrying the leak");
        assert_eq!(
            flag_at_head(&root),
            Value::Bool(true),
            "the fixture must actually commit the leak, or the assertions below \
             are vacuous"
        );

        let outcome = force_clear_auto_chain(&root).unwrap();

        assert!(outcome.working_tree_repaired);
        assert!(
            outcome.committed_tree_repaired,
            "a leak that reached HEAD must be repaired in the commit too, not \
             only in the working tree — otherwise the branch → merge → develop \
             → next-phase-chains path stays open (35.1 D-01)"
        );
        assert_eq!(outcome.commit_refused, None);
        assert_eq!(flag_at_head(&root), Value::Bool(false));
    }

    /// F-8 / T-35.1-08: `commit_path` is path-scoped but still commits whatever
    /// else is dirty IN that path. This repository has already had an incident
    /// where an in-progress file was swept into an unrelated commit
    /// (`CLAUDE.md`), and the correct posture is to refuse rather than sweep.
    ///
    /// **The `HEAD` comparison is the load-bearing assertion.** Asserting only
    /// on the returned `commit_refused` would pass against an implementation
    /// that committed the operator's edit and then reported a refusal.
    #[test]
    fn force_clear_refuses_to_commit_when_the_file_carries_other_changes() {
        let (_dir, root) = git_project();
        write_config(&root, &real_shape(true));
        commit_config(&root, "add gsd config carrying the leak");
        let head_before = head_sha(&root);
        // An operator edit in flight, in the same file, beyond the one key
        // DevFlow owns.
        write_config(
            &root,
            &real_shape(true).replace("\"granularity\": \"medium\"", "\"granularity\": \"large\""),
        );

        let outcome = force_clear_auto_chain(&root).unwrap();

        assert!(
            outcome.working_tree_repaired,
            "the working-tree clear disarms the bypass for THIS run and must \
             happen even when the commit is declined"
        );
        assert!(
            !outcome.committed_tree_repaired,
            "the branch-tip repair must be deferred, not attempted"
        );
        let reason = outcome
            .commit_refused
            .expect("a declined commit must say why, loudly");
        assert!(
            reason.contains("beyond"),
            "the refusal must name the cause — got: {reason}"
        );
        assert!(
            !auto_chain_active(&root).unwrap(),
            "the working tree is still cleared"
        );
        assert!(
            std::fs::read_to_string(config_path(&root))
                .unwrap()
                .contains("\"granularity\": \"large\""),
            "the operator's in-flight edit must survive untouched"
        );
        assert_eq!(
            head_sha(&root),
            head_before,
            "nothing may be committed — this assertion, not the returned \
             refusal, is what distinguishes a genuine refusal from a commit \
             that reported one"
        );
    }

    /// The no-op control. Without it, an implementation that always reported a
    /// repair — or always committed — would satisfy every test above.
    #[test]
    fn force_clear_on_an_already_clean_config_reports_nothing_and_writes_nothing() {
        let (_dir, root) = git_project();
        write_config(&root, &real_shape(false));
        commit_config(&root, "add a clean gsd config");
        let head_before = head_sha(&root);
        let bytes_before = std::fs::read(config_path(&root)).unwrap();

        let outcome = force_clear_auto_chain(&root).unwrap();

        assert!(!outcome.working_tree_repaired);
        assert!(!outcome.committed_tree_repaired);
        assert_eq!(outcome.commit_refused, None);
        assert!(
            !outcome.repaired_anything(),
            "an ordinary clean launch must have nothing to be loud about"
        );
        assert_eq!(std::fs::read(config_path(&root)).unwrap(), bytes_before);
        assert_eq!(head_sha(&root), head_before);
    }

    /// A project that never had GSD config has nothing to leak. A hard error
    /// here would break `devflow start` for every non-GSD project, which is a
    /// far worse failure than the one this repair prevents.
    #[test]
    fn force_clear_on_a_project_without_a_gsd_config_is_a_clean_no_op() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_path_buf();

        let outcome = force_clear_auto_chain(&root).expect("an absent config is not an error");

        assert!(!outcome.working_tree_repaired);
        assert!(!outcome.committed_tree_repaired);
        assert_eq!(outcome.commit_refused, None);
    }

    /// Absent and malformed are different facts and get different answers: a
    /// file that cannot be parsed cannot be certified clear, so it propagates
    /// rather than silently reading as a clean no-op.
    #[test]
    fn force_clear_on_a_malformed_config_is_an_error() {
        let (_dir, root) = git_project();
        let malformed = "{ \"workflow\": { \"_auto_chain_active\": tru";
        write_config(&root, malformed);

        assert!(matches!(
            force_clear_auto_chain(&root),
            Err(GsdConfigError::Json(_))
        ));
        assert_eq!(
            std::fs::read_to_string(config_path(&root)).unwrap(),
            malformed,
            "a failed certification must leave the operator's file exactly as \
             it was"
        );
    }

    /// A project whose `.planning/` exists but holds no config file.
    fn empty_project() -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_path_buf();
        std::fs::create_dir_all(root.join(".planning")).unwrap();
        (dir, root)
    }

    /// Everything except the one key this module owns must survive a write
    /// unchanged — value, position, and serialized form.
    ///
    /// **The assertion is on the file's BYTES, not on a re-parse.** An earlier
    /// version of this test compared `keys()` from two `serde_json::Value`
    /// parses and passed with `preserve_order` REMOVED — because without that
    /// feature both parses go through a `BTreeMap`, so both key lists come out
    /// alphabetized and agree with each other. The comparison normalized away
    /// the exact property it claimed to measure. Comparing raw text is what
    /// makes this discriminating, and it subsumes numeric re-rendering
    /// (`subagent_timeout: 300000`) as well as ordering.
    #[test]
    fn writing_the_flag_leaves_every_other_key_byte_identical() {
        let (_dir, root) = project(REAL_SHAPE);
        let before = std::fs::read_to_string(config_path(&root)).unwrap();

        assert!(set_auto_chain_active(&root, true).unwrap());

        let after = std::fs::read_to_string(config_path(&root)).unwrap();
        let expected = before.replace(
            "\"_auto_chain_active\": false",
            "\"_auto_chain_active\": true",
        );
        assert_ne!(
            expected, before,
            "the fixture must actually contain the key this test flips, or the \
             comparison below is vacuous"
        );
        assert_eq!(
            after, expected,
            "the written file must differ from the original in EXACTLY the one \
             value this module owns — same key order, same number rendering, \
             same whitespace"
        );

        // And the flag really did flip, read back through this module's own
        // accessor rather than by re-reading the text just compared.
        assert!(auto_chain_active(&root).unwrap());
    }

    /// Criterion 3b / D-06: this phase buys checkpoint APPROVAL, not workflow
    /// chaining. Nothing in this codebase may write `workflow.auto_advance`.
    ///
    /// Why it matters concretely: GSD's `check auto-mode` ORs the two flags
    /// (`check-command-router.cjs:107`), so a write that clobbered
    /// `auto_advance` would silently enable the stage-chaining ROADMAP
    /// criterion 3 forbids — and it would do so through a key DevFlow never
    /// intended to touch.
    #[test]
    fn writing_the_flag_never_touches_auto_advance() {
        let (_dir, root) = project(REAL_SHAPE);
        // The fixture has `auto_advance: true` BEFORE the call, deliberately.
        // A fixture where it were `false` would prove nothing: `false` is also
        // what a dropped key deserializes to.
        assert_eq!(
            serde_json::from_str::<Value>(REAL_SHAPE).unwrap()["workflow"]["auto_advance"],
            Value::Bool(true)
        );

        set_auto_chain_active(&root, true).unwrap();
        set_auto_chain_active(&root, false).unwrap();

        let after: Value =
            serde_json::from_str(&std::fs::read_to_string(config_path(&root)).unwrap()).unwrap();
        assert_eq!(
            after["workflow"]["auto_advance"],
            Value::Bool(true),
            "auto_advance is the operator's, and neither setting nor clearing \
             the chain flag may disturb it"
        );
    }

    /// F-3: the ineligible-launch path asserts `false` on every stage launch,
    /// so writing a value the file already holds must cost nothing — otherwise
    /// a tracked file is rewritten on every run of every stage.
    #[test]
    fn setting_the_value_it_already_holds_is_a_no_op() {
        let (_dir, root) = project(REAL_SHAPE);
        let path = config_path(&root);
        let before = std::fs::read(&path).unwrap();
        let before_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();

        // REAL_SHAPE holds `false`; ask for `false`.
        assert!(
            !set_auto_chain_active(&root, false).unwrap(),
            "a write that changes nothing must report that it changed nothing"
        );

        assert_eq!(before, std::fs::read(&path).unwrap());
        assert_eq!(
            before_mtime,
            std::fs::metadata(&path).unwrap().modified().unwrap()
        );

        // Negative control: the same call with the OTHER value must report a
        // change. Without this, a `set_auto_chain_active` that always returned
        // `false` and never wrote would satisfy the assertions above.
        assert!(set_auto_chain_active(&root, true).unwrap());
    }

    /// A config that simply has not grown a `workflow` object yet is a normal
    /// shape, not a corrupt one.
    #[test]
    fn a_missing_workflow_object_is_created_rather_than_rejected() {
        let (_dir, root) = project("{\n  \"commit_docs\": true\n}\n");

        assert!(set_auto_chain_active(&root, true).unwrap());
        assert!(auto_chain_active(&root).unwrap());

        let after: Value =
            serde_json::from_str(&std::fs::read_to_string(config_path(&root)).unwrap()).unwrap();
        assert_eq!(
            after["commit_docs"],
            Value::Bool(true),
            "creating the workflow object must not disturb the keys already there"
        );
    }

    /// ASVS V5: a hand-edited config is untrusted input. It must produce an
    /// `Err` the caller can log and skip, never a panic that would kill a long
    /// unattended run — and the atomic write must not have truncated anything
    /// on the way to failing.
    #[test]
    fn a_malformed_config_is_an_error_not_a_panic() {
        let malformed = "{ \"workflow\": { \"auto_advance\": tru";
        let (_dir, root) = project(malformed);

        // Asserted on the returned Err, never on a caught panic.
        assert!(matches!(
            set_auto_chain_active(&root, true),
            Err(GsdConfigError::Json(_))
        ));
        assert!(matches!(
            auto_chain_active(&root),
            Err(GsdConfigError::Json(_))
        ));
        assert_eq!(
            std::fs::read_to_string(config_path(&root)).unwrap(),
            malformed,
            "a failed write must leave the operator's file exactly as it was"
        );
    }

    /// A JSON document whose root is not an object has nowhere for the key to
    /// live. Refuse rather than replace the operator's file wholesale.
    #[test]
    fn a_non_object_config_root_is_an_error_not_a_replacement() {
        let (_dir, root) = project("[1, 2, 3]\n");

        assert!(matches!(
            set_auto_chain_active(&root, true),
            Err(GsdConfigError::Json(_))
        ));
        assert_eq!(
            std::fs::read_to_string(config_path(&root)).unwrap(),
            "[1, 2, 3]\n"
        );
    }

    /// No file at all is an explicit `Err`, not a silent create — DevFlow does
    /// not own this file and must not conjure one into a project that has no
    /// GSD config.
    #[test]
    fn an_absent_config_is_an_error_not_a_panic() {
        let (_dir, root) = empty_project();

        assert!(matches!(
            set_auto_chain_active(&root, true),
            Err(GsdConfigError::Missing(_))
        ));
        assert!(matches!(
            auto_chain_active(&root),
            Err(GsdConfigError::Missing(_))
        ));
        assert!(
            !config_path(&root).exists(),
            "a failed write must not leave a file behind"
        );
    }

    /// The V5 defensive-default row: three shapes this module does not model,
    /// all of which must read as the INACTIVE value rather than panic.
    ///
    /// Reading via `value["workflow"]["_auto_chain_active"]` would panic on the
    /// first of these; that is exactly the indexing this module forbids on a
    /// read path.
    #[test]
    fn reading_the_flag_defaults_to_the_inactive_value_on_a_shape_it_does_not_recognise() {
        for shape in [
            // `workflow` absent entirely.
            "{ \"commit_docs\": true }",
            // `workflow` present but not an object.
            "{ \"workflow\": \"medium\" }",
            // the key present but not a boolean.
            "{ \"workflow\": { \"_auto_chain_active\": \"true\" } }",
        ] {
            let (_dir, root) = project(shape);
            assert!(
                !auto_chain_active(&root).unwrap(),
                "unrecognised shape must read inactive, not panic: {shape}"
            );
        }

        // Negative control: the shape this module DOES recognise still reads
        // active, so the three assertions above are discriminating rather than
        // a function that always returns `false`.
        let (_dir, root) = project("{ \"workflow\": { \"_auto_chain_active\": true } }");
        assert!(auto_chain_active(&root).unwrap());
    }

    /// The file's trailing-newline convention survives a write, so the diff
    /// stays one line instead of gaining a spurious no-newline-at-EOF marker.
    #[test]
    fn the_trailing_newline_convention_survives_a_write() {
        let (_dir, root) = project(REAL_SHAPE);
        set_auto_chain_active(&root, true).unwrap();
        assert!(
            std::fs::read_to_string(config_path(&root))
                .unwrap()
                .ends_with('\n')
        );

        let without = REAL_SHAPE.trim_end_matches('\n').to_string();
        let (_dir2, root2) = project(&without);
        set_auto_chain_active(&root2, true).unwrap();
        assert!(
            !std::fs::read_to_string(config_path(&root2))
                .unwrap()
                .ends_with('\n')
        );
    }

    /// The write leaves no temporary file behind for a `git add` to sweep up.
    #[test]
    fn the_atomic_write_leaves_no_temp_file_behind() {
        let (_dir, root) = project(REAL_SHAPE);
        set_auto_chain_active(&root, true).unwrap();

        let leftovers: Vec<_> = std::fs::read_dir(root.join(".planning"))
            .unwrap()
            .filter_map(Result::ok)
            .map(|entry| entry.file_name().to_string_lossy().into_owned())
            .filter(|name| name != "config.json")
            .collect();
        assert!(
            leftovers.is_empty(),
            "stray files in .planning: {leftovers:?}"
        );
    }
}