doctrine 0.15.2

Project tooling CLI
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
// SPDX-License-Identifier: GPL-3.0-only
//! `worker_commit` — the gated, server-side self-commit for a jailed dispatch worker
//! (SL-198 PHASE-02, IMP-253; design §5.2/§5.4/§5.5).
//!
//! A dispatch worker runs in a linked worktree with the shared `.git` **read-only**
//! (SL-182 jail), so its raw `git commit` is walled. This MCP tool lets it self-commit
//! ONE gated commit on its own `dispatch/<agent>` branch entirely server-side: the
//! worker passes only an OPAQUE `agent` id (its worktree name) — never a path — and the
//! UNCONFINED MCP server resolves the target, runs the belts, and commits. This is the
//! deliberate bypass of the jail wall (RSK-225), so the belts ARE the security boundary
//! (INV-2: every write path is belt-gated server-side; a worker cannot skip a belt).
//!
//! **Belt order — cheap-first (design §10 X4, authoritative over §5.2's numbering):**
//! resolve → non-empty PRE-fmt delta → scope belt (two-tier) → HEAD==B → the mutating
//! `check commit` gate → stage the pre-fmt classified paths by name (post-fmt CONTENT,
//! PIN-3) → exactly one non-merge commit `C`, `C^==B`. Scoping the PRE-fmt delta before
//! the gate is why cheap-first is mandatory: the gate's `fmt` can widen the touched-path
//! set, so the belt must classify the intended delta first, and the commit then stages
//! exactly that classified set (INV-5 / F2 guard).
//!
//! **DRY (design §8 R3):** the scope hard tier reuses the SAME `.doctrine/`/`.claude/`
//! prefix consts as `classify_import` and the SAME `undeclared_paths` soft predicate, so
//! the two belt callers cannot diverge (VT-3). The resolver, gate, and delta-gather are
//! all reused PHASE-01 / existing seams — no forked copies.

use crate::dispatch_config::ForbiddenWrites;
use crate::git;
use crate::slice::SelectorIntent;
use crate::verify::{CheckKind, CheckPlan, VerificationConfig, resolve_check};
use crate::worktree::{
    CLAUDE_PREFIX, DOCTRINE_PREFIX, DispatchRecord, gather_worktree_delta_paths, resolve_agent,
};
use anyhow::{Context, bail};
use serde::Serialize;
use std::path::Path;

/// Refuse `worker_commit` with no in-scope change — never mint an empty commit (§5.5).
const EMPTY_DELTA: &str = "empty-delta";
/// Refuse: a delta path hit the HARD scope tier (`.doctrine/`/`.claude/` code floor OR
/// the `[dispatch].worker-forbidden-writes` config matcher). The offending path rides in
/// `detail` (§5.2 step 3, EX-6).
const FORBIDDEN_ZONE: &str = "forbidden-zone";
/// Refuse: the worktree `HEAD != B` (resumed / stacked) — no fast-forward past B (§5.5
/// INV-1). Normally subsumed by the resolver's `stale-record` consistency check; kept as
/// a distinct belt-and-suspenders assertion at the commit boundary (§5.2 step 4).
const NOT_AT_BASE: &str = "not-at-base";
/// Refuse: the `check commit` gate (fmt + lint/test/build) exited red; the captured
/// output rides in `detail` (§5.2 step 2).
const COMMIT_GATE_RED: &str = "commit-gate-red";

/// The tool result: a landed commit, or a typed refusal (design §5.2). Serialised
/// externally-tagged (`{"Committed": {…}}` / `{"Refused": {…}}`), matching the other MCP
/// verbs' `{"Verb": {…}}` shape. A refusal is a normal `Ok` result carrying its token —
/// NOT a JSON-RPC error — so the worker/orchestrator reads the reason structurally.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) enum WorkerCommitOutput {
    /// One non-merge commit `C` landed on `dispatch/<agent>` with `C^ == B`. `undeclared`
    /// carries the SOFT-tier src paths outside the design-target selectors (non-blocking —
    /// the orchestrator amends the selectors or rejects at import; §5.2 step 3).
    Committed {
        oid: String,
        base: String,
        undeclared: Vec<String>,
    },
    /// A belt refused; `reason` is the distinct token, `detail` the offending path /
    /// captured output (empty when the token is self-describing).
    Refused { reason: String, detail: String },
}

fn refused(reason: &str, detail: String) -> WorkerCommitOutput {
    WorkerCommitOutput::Refused {
        reason: reason.to_owned(),
        detail,
    }
}

/// The HARD scope tier: a path is a forbidden zone iff it hits the `.doctrine/` OR
/// `.claude/` code floor (the SAME consts `classify_import` rejects on — VT-3 agreement)
/// OR the `[dispatch].worker-forbidden-writes` config matcher (which additionally
/// re-applies the `.doctrine/` floor fail-closed, PIN-2). PURE.
fn is_forbidden_zone(path: &str, forbidden: &ForbiddenWrites) -> bool {
    path.starts_with(DOCTRINE_PREFIX)
        || path.starts_with(CLAUDE_PREFIX)
        || forbidden.is_forbidden(path)
}

/// PURE two-tier scope classification over the PRE-fmt delta (design §5.2 step 3, EX-6).
/// HARD tier: any forbidden-zone path fails closed with `Err(path)` → `forbidden-zone`.
/// SOFT tier: `undeclared_paths` (the SAME predicate `classify_import` uses) returns the
/// src paths outside the `design-target` selectors — these DO NOT block; they ride back
/// as `undeclared` for the orchestrator to amend/reject downstream (bounded by import
/// staying strict, PIN-4). Takes the gathered facts (delta, matcher, selectors) — no git
/// / disk here.
fn classify_scope(
    delta_paths: &[String],
    forbidden: &ForbiddenWrites,
    selectors: &[String],
) -> Result<Vec<String>, String> {
    if let Some(hit) = delta_paths.iter().find(|p| is_forbidden_zone(p, forbidden)) {
        return Err(hit.clone());
    }
    let paths: Vec<&str> = delta_paths.iter().map(String::as_str).collect();
    Ok(crate::conformance::undeclared_paths(selectors, &paths))
}

/// The commit-invariant base gate (§5.2 step 4, INV-1): the worktree `HEAD` must equal the
/// snapshotted base `B` pre-commit, so the one commit `C` satisfies `C^ == B`. PURE.
fn head_at_base(head: &str, base: &str) -> bool {
    head == base
}

/// The `check commit` gate outcome — green, or red with the captured child output.
enum GateOutcome {
    Green,
    Red(String),
}

/// Run the resolved `commit` tier (`resolve_check(Commit)`) in the worker worktree `dir`
/// (design §5.2 step 2). fmt mutates the tree FIRST, then lint/validate/test/build; a
/// red exit captures stdout+stderr for the refusal detail. The gate argv is resolved from
/// the tamper-proof PRIMARY config (`cfg`), never the worker-writable fork copy.
fn run_commit_gate(dir: &Path, cfg: &VerificationConfig) -> anyhow::Result<GateOutcome> {
    let argv = match resolve_check(cfg, CheckKind::Commit) {
        CheckPlan::Run(argv) => argv,
        CheckPlan::Empty(kind) => {
            bail!(
                "worker_commit: [verification].{} is empty — cannot run the commit gate",
                kind.key()
            )
        }
        CheckPlan::Noop(_) => {
            bail!("worker_commit: the commit gate resolved to a no-op — cannot gate the commit")
        }
    };
    let (program, rest) = argv
        .split_first()
        .ok_or_else(|| anyhow::anyhow!("worker_commit: resolved commit-gate argv is empty"))?;
    let output = std::process::Command::new(program)
        .args(rest)
        .current_dir(dir)
        .output()
        .with_context(|| format!("spawning the worker commit gate: {}", argv.join(" ")))?;
    if output.status.success() {
        Ok(GateOutcome::Green)
    } else {
        let mut detail = String::from_utf8_lossy(&output.stdout).into_owned();
        detail.push_str(&String::from_utf8_lossy(&output.stderr));
        Ok(GateOutcome::Red(detail))
    }
}

/// Stage the PRE-fmt classified `paths` by name, then create exactly ONE non-merge
/// commit (design §5.2 step 6, INV-5 / PIN-3). Staging + committing BY PATH (not the
/// post-fmt working-tree diff) means a repo-wide `fmt` that normalised a pre-existing /
/// out-of-scope file cannot ride into the commit — yet the staged CONTENT is the
/// post-fmt content of these paths (the gate already ran), never a pre-fmt blob snapshot.
fn stage_and_commit(dir: &Path, message: &str, paths: &[String]) -> anyhow::Result<()> {
    let mut add_args: Vec<&str> = vec!["add", "--"];
    add_args.extend(paths.iter().map(String::as_str));
    git::git_text(dir, &add_args).context("stage the classified worker delta")?;

    let mut commit_args: Vec<&str> = vec!["commit", "-q", "-m", message, "--"];
    commit_args.extend(paths.iter().map(String::as_str));
    git::git_text(dir, &commit_args).context("create the gated worker commit")?;
    Ok(())
}

/// Resolve the slice's `design-target` selectors for the SOFT tier (design §5.2 step 3).
/// The coordination worktree (`record.coord`) is checked out on `dispatch/<slice>` and
/// carries the authored slice, so the slice id is recovered from its branch and the
/// selectors read from it. Best-effort by design: the SOFT tier never blocks, so a
/// resolution failure degrades to no selectors (every src path reported `undeclared`)
/// rather than refusing a legitimate commit.
fn design_target_selectors(record: &DispatchRecord) -> anyhow::Result<Vec<String>> {
    let branch = git::git_text(&record.coord, &["rev-parse", "--abbrev-ref", "HEAD"])?;
    let slice_id: u32 = branch
        .strip_prefix("dispatch/")
        .and_then(|s| s.parse().ok())
        .ok_or_else(|| anyhow::anyhow!("coord HEAD `{branch}` is not a dispatch/<slice> branch"))?;
    crate::slice::selectors(&record.coord, slice_id, Some(SelectorIntent::DesignTarget))
}

/// Resolve a commit oid in `dir` (`rev-parse <rev>^{commit}`).
fn commit_oid(dir: &Path, rev: &str) -> anyhow::Result<String> {
    git::git_text(dir, &["rev-parse", &format!("{rev}^{{commit}}")])
        .with_context(|| format!("rev-parse {rev} in {}", dir.display()))
}

/// The gated server-side worker self-commit (design §5.2; belt order §10 X4). Resolves
/// the opaque `agent` to its per-worktree record, runs the cheap-first belts over the
/// worker's live PRE-fmt delta, gates with `check commit`, and lands exactly one
/// non-merge commit on `dispatch/<agent>`. `root` is the PRIMARY (unconfined) MCP root —
/// the tamper-proof source of the forbidden-write config + the gate argv (both under the
/// worker-unwritable `.doctrine/` floor). A belt refusal is a normal `Ok(Refused{…})`.
pub(crate) fn run_worker_commit(
    root: &Path,
    agent: &str,
    message: &str,
) -> anyhow::Result<WorkerCommitOutput> {
    // 1. Resolve the opaque agent-id → its per-worktree record (PHASE-01). No worker path
    //    enters resolution; a typed resolver refusal maps 1:1 to our refusal token.
    let record = match resolve_agent(root, agent) {
        Ok(record) => record,
        Err(refusal) => return Ok(refused(refusal.token(), String::new())),
    };

    // 2. Capture the PRE-fmt in-scope delta (tracked diff vs HEAD + untracked adds) — the
    //    worker cannot self-commit, so its change is the live working-tree delta at B.
    let delta = gather_worktree_delta_paths(&record.dir)?;
    if delta.is_empty() {
        return Ok(refused(EMPTY_DELTA, String::new()));
    }

    // Config from the PRIMARY root (tamper-proof; worker-unwritable — §5.3).
    let cfg = crate::dtoml::load_doctrine_toml(root)?;
    let forbidden = cfg.dispatch.forbidden_writes();
    let selectors = design_target_selectors(&record).unwrap_or_default();

    // 3. Scope belt (cheap-first, over the PRE-fmt delta): HARD refuses, SOFT rides back.
    let undeclared = match classify_scope(&delta, &forbidden, &selectors) {
        Ok(undeclared) => undeclared,
        Err(path) => return Ok(refused(FORBIDDEN_ZONE, path)),
    };

    // 4. Commit invariant — HEAD == B (pre-commit).
    let base = commit_oid(&record.dir, &record.base)?;
    let head = commit_oid(&record.dir, "HEAD")?;
    if !head_at_base(&head, &base) {
        return Ok(refused(NOT_AT_BASE, String::new()));
    }

    // 5. Gate — the mutating `check commit` (fmt first) in the worker worktree.
    match run_commit_gate(&record.dir, &cfg.verification)? {
        GateOutcome::Green => {}
        GateOutcome::Red(detail) => return Ok(refused(COMMIT_GATE_RED, detail)),
    }

    // 6. Stage the PRE-fmt classified paths (post-fmt content) + one non-merge commit.
    stage_and_commit(&record.dir, message, &delta)?;

    let oid = commit_oid(&record.dir, "HEAD")?;
    // Exactly one non-merge commit, C^ == B (INV-1) — no indexing (clippy floor).
    match git::parents(&record.dir, &oid)?.as_slice() {
        [parent] if *parent == base => {}
        [parent] => bail!("worker_commit parent {parent} != base {base} (C^ != B)"),
        other => bail!(
            "worker_commit produced a commit with {} parents (expected exactly 1)",
            other.len()
        ),
    }

    Ok(WorkerCommitOutput::Committed {
        oid,
        base,
        undeclared,
    })
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "tests: fail-fast unwrap on fixture setup is idiomatic"
)]
mod tests {
    use super::*;
    use crate::dispatch_config::DispatchConfig;
    use crate::worktree::{Apply, Refusal, classify_import, provision_dispatch_record};
    use std::fs;
    use std::path::PathBuf;
    use std::process::Command;

    fn forbidden_from(lines: &[&str]) -> ForbiddenWrites {
        let cfg = DispatchConfig {
            worker_forbidden_writes: lines.iter().map(|s| (*s).to_string()).collect(),
            ..DispatchConfig::default()
        };
        cfg.forbidden_writes()
    }

    // --- VT-1 / VT-3 / VT-5: the PURE scope belt --------------------------------------

    #[test]
    fn classify_scope_hard_refuses_the_doctrine_and_claude_floors() {
        // The `.doctrine/`/`.claude/` code floor fails closed even with an EMPTY config —
        // it is not sourced from the matcher (VT-1 forbidden-zone).
        let fw = forbidden_from(&[]);
        assert_eq!(
            classify_scope(&[".doctrine/state/x".to_string()], &fw, &[]),
            Err(".doctrine/state/x".to_string())
        );
        assert_eq!(
            classify_scope(&[".claude/agents/w.md".to_string()], &fw, &[]),
            Err(".claude/agents/w.md".to_string())
        );
    }

    #[test]
    fn classify_scope_hard_refuses_a_config_forbidden_agent_def_or_flake() {
        // The config matcher hard-fences agent-defs + flake.nix (the template defaults).
        let fw = forbidden_from(&["flake.nix", "install/agents/**"]);
        assert_eq!(
            classify_scope(&["flake.nix".to_string()], &fw, &[]),
            Err("flake.nix".to_string())
        );
        assert_eq!(
            classify_scope(
                &["install/agents/claude/dispatch-worker.md".to_string()],
                &fw,
                &[]
            ),
            Err("install/agents/claude/dispatch-worker.md".to_string())
        );
    }

    #[test]
    fn classify_scope_soft_reports_undeclared_without_blocking() {
        // VT-5 (soft): a src write outside the design-target selectors COMMITS (Ok) and
        // rides back as `undeclared`; a declared path does not.
        let fw = forbidden_from(&[]);
        let selectors = vec!["src/allowed.rs".to_string()];
        let delta = vec!["src/allowed.rs".to_string(), "src/other.rs".to_string()];
        assert_eq!(
            classify_scope(&delta, &fw, &selectors),
            Ok(vec!["src/other.rs".to_string()])
        );
    }

    #[test]
    fn classify_scope_forbidden_wins_even_alongside_an_undeclared_path() {
        // VT-5 (hard still wins): a forbidden-zone write in the same call hard-refuses,
        // regardless of a co-present undeclared src path.
        let fw = forbidden_from(&[]);
        let selectors = vec!["src/allowed.rs".to_string()];
        let delta = vec!["src/other.rs".to_string(), ".doctrine/x".to_string()];
        assert_eq!(
            classify_scope(&delta, &fw, &selectors),
            Err(".doctrine/x".to_string())
        );
    }

    #[test]
    fn worker_commit_and_classify_import_agree_on_the_hard_verdict() {
        // VT-3: both belts REJECT the same `.doctrine/`/`.claude/` change-set and ACCEPT
        // the same benign src set — the shared consts + `undeclared_paths` predicate, not
        // a forked copy. `classify_import`'s stationary-head preconds are all true here so
        // only the scope verdict differs.
        let fw = forbidden_from(&[]);
        for hard in [".doctrine/state/x", ".claude/agents/w.md"] {
            let delta = vec![hard.to_string()];
            assert!(
                classify_scope(&delta, &fw, &[]).is_err(),
                "worker_commit rejects {hard}"
            );
            assert!(
                classify_import(true, true, true, &delta, &[]).is_err(),
                "classify_import rejects {hard}"
            );
        }
        // A benign src path: both accept.
        let benign = vec!["src/lib.rs".to_string()];
        assert!(classify_scope(&benign, &fw, &[]).is_ok());
        assert_eq!(
            classify_import(true, true, true, &benign, &[]),
            Ok(Apply::Ok)
        );
        // And the specific import token for a `.doctrine/` path is DoctrineTouch — the
        // hard verdict worker_commit mirrors as `forbidden-zone`.
        assert_eq!(
            classify_import(true, true, true, &[".doctrine/x".to_string()], &[]),
            Err(Refusal::DoctrineTouch)
        );
    }

    #[test]
    fn head_at_base_is_exact_equality() {
        assert!(head_at_base("abc123", "abc123"));
        assert!(!head_at_base("abc123", "def456"));
    }

    // --- integration: a live per-worktree record + a real linked worktree -------------

    fn git_run(dir: &Path, args: &[&str]) -> String {
        let out = Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .output()
            .unwrap();
        assert!(
            out.status.success(),
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    }

    /// Stand up a primary repo at base B with a linked worker worktree on
    /// `dispatch/<name>` at `<coord>/.worktrees/<name>`, plus its per-worktree record.
    /// `commit_override` is written into the primary `[verification].commit` (the gate).
    /// Returns `(tmp, primary, wt, agent, base)`.
    fn worker_fixture(
        commit_override: &str,
        seed_files: &[(&str, &str)],
    ) -> (tempfile::TempDir, PathBuf, PathBuf, String, String) {
        let tmp = tempfile::tempdir().unwrap();
        let primary = fs::canonicalize(tmp.path()).unwrap().join("primary");
        fs::create_dir_all(&primary).unwrap();
        git_run(&primary, &["init", "-q", "-b", "main"]);
        git_run(&primary, &["config", "user.email", "t@t"]);
        git_run(&primary, &["config", "user.name", "t"]);
        // Base tree.
        for (rel, body) in seed_files {
            let path = primary.join(rel);
            fs::create_dir_all(path.parent().unwrap()).unwrap();
            fs::write(&path, body).unwrap();
        }
        fs::write(primary.join("seed"), "base\n").unwrap();
        git_run(&primary, &["add", "-A"]);
        git_run(&primary, &["commit", "-q", "-m", "base"]);
        let base = git_run(&primary, &["rev-parse", "HEAD^{commit}"]);

        // The tamper-proof gate config lives on the PRIMARY (the MCP root).
        fs::create_dir_all(primary.join(".doctrine")).unwrap();
        fs::write(
            primary.join(".doctrine/doctrine.toml"),
            format!("[verification]\ncommit = {commit_override}\n"),
        )
        .unwrap();

        // The worker worktree + its per-worktree record.
        let agent = "wk1".to_string();
        let coord = fs::canonicalize(tmp.path()).unwrap().join("coord");
        let wt = coord.join(".worktrees").join(&agent);
        fs::create_dir_all(coord.join(".worktrees")).unwrap();
        git_run(
            &primary,
            &[
                "worktree",
                "add",
                "-q",
                "-b",
                &format!("dispatch/{agent}"),
                wt.to_str().unwrap(),
                &base,
            ],
        );
        let wt = fs::canonicalize(&wt).unwrap();
        provision_dispatch_record(&coord, &agent, &base, &wt, &format!("dispatch/{agent}"))
            .unwrap();

        (tmp, primary, wt, agent, base)
    }

    #[test]
    fn worker_commit_unknown_agent_refuses() {
        // VT-1: the resolver refusal token surfaces as our refusal.
        let (_tmp, primary, _wt, _agent, _base) = worker_fixture("[\"true\"]", &[]);
        let out = run_worker_commit(&primary, "nosuchagent", "msg").unwrap();
        assert_eq!(
            out,
            refused("unknown-agent", String::new()),
            "an unresolvable agent refuses unknown-agent"
        );
    }

    #[test]
    fn worker_commit_empty_delta_refuses() {
        // VT-1: no in-scope change ⇒ empty-delta (no empty commit).
        let (_tmp, primary, _wt, agent, _base) = worker_fixture("[\"true\"]", &[]);
        let out = run_worker_commit(&primary, &agent, "msg").unwrap();
        assert_eq!(out, refused(EMPTY_DELTA, String::new()));
    }

    #[test]
    fn worker_commit_gate_red_refuses_and_leaves_head_at_base() {
        // VT-1: a red gate refuses commit-gate-red; the branch stays at B (no commit).
        let (_tmp, primary, wt, agent, base) = worker_fixture("[\"false\"]", &[]);
        fs::write(wt.join("seed"), "worker change\n").unwrap();
        let out = run_worker_commit(&primary, &agent, "msg").unwrap();
        match out {
            WorkerCommitOutput::Refused { reason, .. } => assert_eq!(reason, COMMIT_GATE_RED),
            other => panic!("expected commit-gate-red, got {other:?}"),
        }
        assert_eq!(git_run(&wt, &["rev-parse", "HEAD^{commit}"]), base);
    }

    #[test]
    fn worker_commit_forbidden_zone_refuses() {
        // VT-1: a `.doctrine/` write hard-refuses forbidden-zone (before the gate runs).
        let (_tmp, primary, wt, agent, base) = worker_fixture("[\"true\"]", &[]);
        fs::create_dir_all(wt.join(".doctrine/state")).unwrap();
        fs::write(wt.join(".doctrine/state/x"), "sneaky\n").unwrap();
        let out = run_worker_commit(&primary, &agent, "msg").unwrap();
        match out {
            WorkerCommitOutput::Refused { reason, detail } => {
                assert_eq!(reason, FORBIDDEN_ZONE);
                assert!(
                    detail.contains(".doctrine/state/x"),
                    "names the path: {detail}"
                );
            }
            other => panic!("expected forbidden-zone, got {other:?}"),
        }
        assert_eq!(git_run(&wt, &["rev-parse", "HEAD^{commit}"]), base);
    }

    #[test]
    fn worker_commit_happy_path_lands_one_non_merge_commit() {
        // VT-2: an in-scope, gate-green tree → one non-merge commit, C^==B, parents==1.
        let (_tmp, primary, wt, agent, base) = worker_fixture("[\"true\"]", &[]);
        fs::write(wt.join("seed"), "worker change\n").unwrap();
        let out = run_worker_commit(&primary, &agent, "the message").unwrap();
        let (oid, out_base) = match out {
            WorkerCommitOutput::Committed { oid, base, .. } => (oid, base),
            other => panic!("expected Committed, got {other:?}"),
        };
        assert_eq!(out_base, base, "returned base == B");
        // The branch advanced B → C, exactly one non-merge commit, C^ == B.
        assert_eq!(git_run(&wt, &["rev-parse", "HEAD^{commit}"]), oid);
        let parents = git_run(&wt, &["rev-list", "--parents", "-n", "1", &oid]);
        let cols: Vec<&str> = parents.split_whitespace().collect();
        assert_eq!(cols.len(), 2, "exactly one parent: {parents}");
        assert_eq!(cols[1], base, "C^ == B");
        assert_eq!(git_run(&wt, &["log", "-1", "--format=%s"]), "the message");
    }

    #[test]
    fn worker_commit_stages_only_the_in_scope_path_after_the_gate_fmt() {
        // VT-4 (F2 guard): the gate reformats a pre-existing out-of-scope file; the
        // commit contains ONLY the in-scope path the worker touched, not the file the
        // gate rewrote. The "fmt" is simulated by a gate that mutates outofscope.txt.
        let (_tmp, primary, wt, agent, _base) = worker_fixture(
            "[\"sh\", \"-c\", \"printf reformatted > outofscope.txt\"]",
            &[
                ("outofscope.txt", "misformatted\n"),
                ("src/feature.rs", "fn f(){}\n"),
            ],
        );
        // Worker touches only the in-scope file.
        fs::write(wt.join("src/feature.rs"), "fn f() {}\n").unwrap();
        let out = run_worker_commit(&primary, &agent, "in-scope only").unwrap();
        let oid = match out {
            WorkerCommitOutput::Committed { oid, .. } => oid,
            other => panic!("expected Committed, got {other:?}"),
        };
        let touched = git_run(&wt, &["show", "--name-only", "--format=", &oid]);
        let names: Vec<&str> = touched.lines().filter(|l| !l.is_empty()).collect();
        assert_eq!(
            names,
            vec!["src/feature.rs"],
            "commit contains ONLY the in-scope path, not the gate-reformatted file"
        );
    }

    #[test]
    fn worker_commit_soft_undeclared_still_commits() {
        // VT-5 (shell): a src write outside the (here empty) design-target selectors
        // COMMITS and returns the path as `undeclared` — never dropped, never blocked.
        let (_tmp, primary, wt, agent, _base) = worker_fixture("[\"true\"]", &[]);
        fs::write(wt.join("seed"), "worker change\n").unwrap();
        let out = run_worker_commit(&primary, &agent, "msg").unwrap();
        match out {
            WorkerCommitOutput::Committed { undeclared, .. } => {
                assert!(
                    undeclared.contains(&"seed".to_string()),
                    "the out-of-selector src path is reported undeclared: {undeclared:?}"
                );
            }
            other => panic!("expected Committed, got {other:?}"),
        }
    }
}