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}