doctrine 0.14.0

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
// SPDX-License-Identifier: GPL-3.0-only
//! Worker identity — disk marker primary (SL-056 §3, pure core).
//! `Cause`, `StatusLine`, `describe_mode`, marker file ops, `run_status`, `run_marker_clear`.

use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};

use anyhow::{Context, bail};

use crate::root;

use super::shared::is_linked_worktree;

// ---------------------------------------------------------------------------
// Cause & StatusLine — pure core
// ---------------------------------------------------------------------------

/// Which signal(s) put the process in worker mode, if any. The single source for
/// BOTH the `worktree status` human line AND the `--assert` exit — no
/// `classify_writable` twin (design §3, anti-parallel-implementation).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Cause {
    /// Neither signal — writes allowed (direct/solo writer).
    None,
    /// Marker present in a linked worktree (the PRIMARY, harness-agnostic signal).
    Marker,
    /// `DOCTRINE_WORKER` env set (the codex/pi worker-on-main optimisation).
    Env,
    /// Both legs trip at once.
    Both,
}

impl Cause {
    /// The `signal: <token>` word for the human status line / refusals.
    fn token(self) -> &'static str {
        match self {
            Cause::None => "none",
            Cause::Marker => "marker",
            Cause::Env => "env",
            Cause::Both => "both",
        }
    }
}

/// The resolved worker-mode verdict: whether writes are refused, the cause, and
/// the `is_linked` context the dual-cause message needs. Minimal pure data —
/// derived by [`describe_mode`] and consumed by both the human line and the
/// `--assert` exit so the two can never disagree (design §3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StatusLine {
    /// True iff a write-classed verb would be refused.
    pub(crate) refused: bool,
    /// Which signal(s) caused the refusal (`None` when allowed).
    pub(crate) cause: Cause,
    /// Whether the resolved root is a linked worktree (for the dual-cause split).
    pub(crate) is_linked: bool,
}

impl StatusLine {
    /// A stale/stray marker: the env leg is NOT involved, the marker is present,
    /// but it sits in a linked worktree without env — the `--assert` stale-marker
    /// case the operator must clear. Derived from the SAME state the human line
    /// reads (design §3): `cause == Marker` already encodes "marker-only, linked".
    pub(crate) fn is_stale_marker(self) -> bool {
        self.cause == Cause::Marker
    }

    /// The env leg tripped on a tree that is NOT a linked worktree — the
    /// dual-cause hazard (a worker dropped on the coordination root, or a leaked
    /// env). Distinct from a marker fork; carries the named dual-cause message.
    pub(crate) fn is_env_on_nonlinked(self) -> bool {
        matches!(self.cause, Cause::Env | Cause::Both) && !self.is_linked
    }

    /// The `signal: <token>` word for the human status line and refusals.
    pub(crate) fn cause_token(self) -> &'static str {
        self.cause.token()
    }
}

/// Resolve worker mode from the three primitive signals (design §3 truth table).
/// PURE — the caller's shell supplies `is_linked` (git), `marker_present` (disk),
/// and `env_set` (env). The marker leg trips ONLY in a linked worktree (a marker
/// on the primary tree is inert — mode, not location, decides, but the marker's
/// reach is the linked fork). The env leg trips anywhere (the worker-on-main
/// catch).
pub(crate) fn describe_mode(is_linked: bool, marker_present: bool, env_set: bool) -> StatusLine {
    let marker_leg = is_linked && marker_present;
    let cause = match (marker_leg, env_set) {
        (true, true) => Cause::Both,
        (true, false) => Cause::Marker,
        (false, true) => Cause::Env,
        (false, false) => Cause::None,
    };
    StatusLine {
        refused: marker_leg || env_set,
        cause,
        is_linked,
    }
}

// ---------------------------------------------------------------------------
// Constants & marker file ops
// ---------------------------------------------------------------------------

pub(crate) const DISPATCH_WORKER_AGENT_TYPE: &str = "dispatch-worker";

/// The withheld-tier marker the trusted orchestrator stamps before a worker runs.
/// Presence-only (no contents). Sits under `.doctrine/state/**`, so it inherits
/// every gitignore / provision-drop / import-exclude rule with zero new tier
/// logic (design §3; the `is_withheld` test pins it to [`Tier::State`]).
pub(crate) fn marker_path(root: &Path) -> PathBuf {
    root.join(".doctrine/state/dispatch/worker")
}

/// True iff the worker marker file exists at `root`. Disk read (shell).
pub(crate) fn marker_present(root: &Path) -> bool {
    marker_path(root).exists()
}

/// True iff `DOCTRINE_WORKER` is set to `1` — the codex/pi worker-on-main
/// OPTIMISATION (design §3), not the identity. Cheap (env only), evaluated before
/// the marker leg so a Read verb in a non-doctrine cwd never gains a git/disk
/// failure path. `pub(crate)` so the rootless-cwd guard fallback can consult the
/// env leg alone when `root::find` errors (no marker leg without a root).
pub(crate) fn env_worker_set() -> bool {
    std::env::var_os("DOCTRINE_WORKER").as_deref() == Some(std::ffi::OsStr::new("1"))
}

/// Stamp the worker marker at `root` (mkdir-p the dispatch dir). Shell.
/// The non-test consumer is [`run_fork`] under `--worker` (SL-056 PHASE-06).
pub(crate) fn write_marker(root: &Path) -> anyhow::Result<()> {
    let path = marker_path(root);
    if let Some(dir) = path.parent() {
        fs::create_dir_all(dir)
            .with_context(|| format!("create dispatch marker dir {}", dir.display()))?;
    }
    #[expect(clippy::disallowed_methods, reason = "runtime worker marker")]
    fs::write(&path, b"").with_context(|| format!("write worker marker {}", path.display()))?;
    Ok(())
}

/// Remove the worker marker at `root` (idempotent — absent ⇒ Ok). Shell.
pub(crate) fn remove_marker(root: &Path) -> anyhow::Result<()> {
    let path = marker_path(root);
    match fs::remove_file(&path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e).with_context(|| format!("remove worker marker {}", path.display())),
    }
}

/// Resolve the full worker-mode verdict at `root` through the single pure
/// [`describe_mode`] — the design's `worker_mode(root) = (is_linked_worktree(root)
/// && marker_present(root)) OR env` predicate (its `.refused` field). The env leg
/// is checked first (cheap); the marker leg (`is_linked_worktree` +
/// [`marker_present`]) only matters in a linked worktree, and a git failure there
/// is treated as not-linked (the verdict degrades to the env leg, never a new
/// error path — design §3 lazy-marker note).
pub(crate) fn resolve_mode(root: &Path) -> StatusLine {
    let env_set = env_worker_set();
    let is_linked = is_linked_worktree(root).unwrap_or(false);
    let marker = is_linked && marker_present(root);
    describe_mode(is_linked, marker, env_set)
}

/// The named dual-cause refusal substance for the env leg on a NON-linked tree
/// (design §3). Stable tokens — goldens assert this. Never a bare "worker
/// refused"; the caller also names the verb.
pub(crate) const DUAL_CAUSE: &str = "`DOCTRINE_WORKER` set outside a worker worktree: a worker was dropped on the coordination root → re-dispatch isolated; or the env leaked into this process → unset it";

// ---------------------------------------------------------------------------
// worktree status / marker --clear (SL-056 §3, the observability + cure verbs)
// ---------------------------------------------------------------------------

/// `doctrine worktree status [--assert]` (Read-classed). Prints the resolved
/// mode and cause from the SINGLE [`describe_mode`] verdict; `--assert` derives a
/// non-zero `stale-marker` exit from the SAME state (design §3 — the human line
/// and the `--assert` exit can never disagree).
pub(crate) fn run_status(path: Option<PathBuf>, assert: bool) -> anyhow::Result<()> {
    let root = root::find(path, &root::default_markers())?;
    let mode = resolve_mode(&root);

    if mode.refused {
        writeln!(
            io::stdout(),
            "worker fork: yes — writes refused; signal: {}",
            mode.cause_token()
        )?;
    } else {
        writeln!(io::stdout(), "worker fork: no — writes allowed")?;
    }

    if assert && mode.is_stale_marker() {
        bail!(
            "stale-marker: a worker marker is present in this linked worktree but no dispatch is active — clear it with `doctrine worktree marker --clear --operator`"
        );
    }
    Ok(())
}

/// `doctrine worktree marker --clear [--operator]` (bespoke `MarkerClear` class —
/// never refused by the marker conjunct itself; design §3 §5). Removes the marker
/// at the cwd tree root with a loud receipt. Bespoke refusals:
/// - `DOCTRINE_WORKER` set (clear it from a process without the env leg);
/// - cwd is NOT the marker's own tree root (refuse a remote clear);
/// - cwd tree is a LINKED worktree and `--operator` is absent (the accident-fence).
pub(crate) fn run_marker_clear(path: Option<PathBuf>, operator: bool) -> anyhow::Result<()> {
    if env_worker_set() {
        bail!(
            "refusing `marker --clear` while `DOCTRINE_WORKER` is set — run it from a process without the env leg (unset DOCTRINE_WORKER)"
        );
    }

    let root = root::find(path, &root::default_markers())?;
    let root =
        fs::canonicalize(&root).with_context(|| format!("canonicalize root {}", root.display()))?;
    let cwd = std::env::current_dir().context("current dir")?;
    let cwd =
        fs::canonicalize(&cwd).with_context(|| format!("canonicalize cwd {}", cwd.display()))?;
    let linked = is_linked_worktree(&root).unwrap_or(false);
    if linked && !operator {
        bail!(
            "refusing `marker --clear` in a linked worktree without `--operator` — this is the accident-fence; pass `--operator` to confirm you are the trusted orchestrator"
        );
    }

    // For linked worktrees operated from outside (primary tree), skip the
    // cwd check — cwd will never match. When inside the linked worktree
    // itself, enforce cwd == root (refuse clear from a subdirectory).
    let enforce_cwd = !linked || cwd.starts_with(&root);
    if enforce_cwd && cwd != root {
        bail!(
            "refusing `marker --clear`: cwd {} is not the marker's tree root {} — run it from the tree root",
            cwd.display(),
            root.display()
        );
    }

    let existed = marker_present(&root);
    remove_marker(&root)?;
    if existed {
        writeln!(
            io::stdout(),
            "CLEARED worker marker at {} — writes restored",
            marker_path(&root).display()
        )?;
    } else {
        writeln!(
            io::stdout(),
            "no worker marker at {} — nothing to clear",
            marker_path(&root).display()
        )?;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    /// These tests assert env-UNSET behaviour. Under `DOCTRINE_WORKER=1` (the
    /// dispatch worker gate, e.g. a subagent running the gate) the env leg
    /// dominates `run_marker_clear`/`env_worker_set`, so they are inapplicable —
    /// skip rather than fail. The env-SET path has its own coverage.
    fn skip_under_worker() -> bool {
        if env_worker_set() {
            eprintln!("skipping: DOCTRINE_WORKER set — env-unset test inapplicable");
            return true;
        }
        false
    }

    // --- SL-056 PHASE-05 T1: describe_mode truth table (the single source) ---

    #[test]
    fn describe_mode_truth_table() {
        // Solo: neither signal, in or out of a linked worktree ⇒ allowed.
        let solo_plain = describe_mode(false, false, false);
        assert!(!solo_plain.refused, "no signal ⇒ writes allowed");
        assert_eq!(solo_plain.cause, Cause::None);

        // A marker on the PRIMARY tree is inert (mode needs a linked fork).
        let marker_on_main = describe_mode(false, true, false);
        assert!(
            !marker_on_main.refused,
            "marker without a linked worktree is inert ⇒ allowed"
        );
        assert_eq!(marker_on_main.cause, Cause::None);

        // A linked worktree WITHOUT a marker (the clean direct-writer entry).
        let linked_no_marker = describe_mode(true, false, false);
        assert!(!linked_no_marker.refused, "linked, no marker ⇒ allowed");
        assert_eq!(linked_no_marker.cause, Cause::None);

        // PRIMARY signal: marker in a linked worktree, no env ⇒ refused: marker.
        let marker = describe_mode(true, true, false);
        assert!(marker.refused);
        assert_eq!(marker.cause, Cause::Marker);
        assert!(
            marker.is_stale_marker(),
            "marker-only in a fork is the stale-marker case"
        );
        assert!(!marker.is_env_on_nonlinked());

        // Env on a NON-linked tree ⇒ refused: env, dual-cause hazard.
        let env_main = describe_mode(false, false, true);
        assert!(env_main.refused);
        assert_eq!(env_main.cause, Cause::Env);
        assert!(env_main.is_env_on_nonlinked(), "env on main ⇒ dual-cause");
        assert!(!env_main.is_stale_marker());

        // Env inside a linked worktree (no marker) ⇒ env, but NOT the dual-cause
        // (it is genuinely a worker fork via the env optimisation).
        let env_linked = describe_mode(true, false, true);
        assert!(env_linked.refused);
        assert_eq!(env_linked.cause, Cause::Env);
        assert!(!env_linked.is_env_on_nonlinked());

        // Both legs ⇒ signal: both.
        let both = describe_mode(true, true, true);
        assert!(both.refused);
        assert_eq!(both.cause, Cause::Both);
        assert!(
            !both.is_stale_marker(),
            "both is not the marker-only stale case"
        );

        assert_eq!(solo_plain.cause_token(), "none");
        assert_eq!(marker.cause_token(), "marker");
        assert_eq!(env_main.cause_token(), "env");
        assert_eq!(both.cause_token(), "both");
    }

    // SL-056 §3 T2: write_marker / remove_marker / marker_present round-trip.
    #[test]
    fn marker_write_present_remove_round_trip() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        assert!(!marker_present(root), "no marker initially");
        write_marker(root).unwrap();
        assert!(marker_present(root), "marker present after write");
        assert!(
            marker_path(root).exists(),
            "marker file exists under .doctrine/state/dispatch/worker"
        );
        remove_marker(root).unwrap();
        assert!(!marker_present(root), "marker gone after remove");
        // Idempotent: removing an absent marker is Ok.
        remove_marker(root).unwrap();
    }

    #[test]
    fn env_worker_set_reads_the_env_flag() {
        if skip_under_worker() {
            return;
        }
        assert!(
            !env_worker_set(),
            "DOCTRINE_WORKER should not be set in the test harness"
        );
    }

    // --- SL-056 §3 T3: run_marker_clear goldens (VT-3) ---

    #[test]
    fn run_marker_clear_refuses_without_operator_in_linked_worktree() {
        if skip_under_worker() {
            return;
        }
        // Create a linked worktree, mark it, try to clear without `--operator`.
        let tmp = tempfile::tempdir().unwrap();
        let primary = super::super::test_helpers::init_repo(&tmp.path().join("src"));
        let fork = tmp.path().join("fork");
        super::super::test_helpers::git(
            &primary,
            &[
                "worktree",
                "add",
                "-q",
                "-b",
                "feat",
                fork.to_str().unwrap(),
            ],
        );
        let fork = std::fs::canonicalize(&fork).unwrap();
        write_marker(&fork).unwrap();
        let err = run_marker_clear(Some(fork.clone()), false).unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("--operator"),
            "should refuse without --operator: {msg}"
        );
    }

    #[test]
    fn run_marker_clear_with_operator_clears_in_linked_worktree() {
        if skip_under_worker() {
            return;
        }
        let tmp = tempfile::tempdir().unwrap();
        let primary = super::super::test_helpers::init_repo(&tmp.path().join("src"));
        let fork = tmp.path().join("fork");
        super::super::test_helpers::git(
            &primary,
            &[
                "worktree",
                "add",
                "-q",
                "-b",
                "feat",
                fork.to_str().unwrap(),
            ],
        );
        let fork = std::fs::canonicalize(&fork).unwrap();
        write_marker(&fork).unwrap();
        run_marker_clear(Some(fork.clone()), true).unwrap();
        assert!(!marker_present(&fork));
    }

    // --- SL-056 §3 T4: run_status goldens (VT-4) ---

    #[test]
    fn run_status_no_marker_no_env_reports_writes_allowed() {
        let tmp = tempfile::tempdir().unwrap();
        let root = super::super::test_helpers::init_repo(&tmp.path().join("src"));
        // Should succeed without panicking.
        run_status(Some(root), false).unwrap();
    }
}