pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
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
//! Warm-path plumbing (spec §4.3): `PUSHKIN_DAEMON` mode resolution, the
//! shim-side warm-or-cold check, and the `daemon serve` entrypoint. Mode is
//! env-resolved until the daemon's persisted config lands (the Phase-3
//! nudge-mode pattern): unset probes the socket and never spawns; `auto`
//! auto-starts the daemon on first connect; `off` never touches the socket.
//! Every failure on the warm path degrades to the cold in-process pipeline —
//! the daemon being down, stale, or confused can slow a check, never skip it.

use anyhow::Result;
use pushkin_core::envelope::CheckResult;
use pushkin_core::manifest::Manifest;
use pushkin_core::pipeline::{check_write, WriteRequest};
use pushkin_daemon::protocol::{Request, Response, PROTOCOL_VERSION};
use pushkin_daemon::server::{self, ServerError};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
    Off,
    Probe,
    Auto,
}

fn mode() -> Mode {
    match std::env::var("PUSHKIN_DAEMON").as_deref() {
        Ok("off") => Mode::Off,
        Ok("auto") => Mode::Auto,
        _ => Mode::Probe,
    }
}

/// Which path produced a verdict, and — when a warm attempt was made and
/// failed — why (F74). Degrading to cold is usually correct and this does not
/// change *when* it happens; the standing rule is that a degradation must be
/// **observable**, and before F74 a failed warm attempt and no daemon at all
/// were indistinguishable: same cold verdict, same silence.
enum ServedBy {
    Warm,
    ColdDisabled,
    ColdNoDaemon,
    ColdWarmFailed(String),
}

impl ServedBy {
    /// Reported on stderr, never on stdout: stdout is the verdict channel and
    /// an `allow` on the Claude hook surface prints nothing at all.
    fn notice(&self) -> String {
        match self {
            Self::Warm => "pushkin: verdict served by the warm daemon.".to_owned(),
            Self::ColdDisabled => "pushkin: warm path disabled (PUSHKIN_DAEMON=off); \
                 verdict served by the cold pipeline."
                .to_owned(),
            Self::ColdNoDaemon => "pushkin: no warm daemon answering; verdict served by \
                 the cold pipeline."
                .to_owned(),
            Self::ColdWarmFailed(reason) => format!(
                "pushkin: the warm path was attempted and failed ({reason}); \
                 verdict served by the cold pipeline."
            ),
        }
    }
}

/// The shim's one entry point: warm verdict when a daemon answers, cold
/// pipeline otherwise. **The verdict is identical either way** — that parity is
/// the daemon's whole contract and is pinned by the conformance tests. What
/// differs is the record: which path served is disclosed on stderr (F74).
pub fn check_or_cold(manifest: &Manifest, request: &WriteRequest) -> CheckResult {
    let (warm, served) = warm_check(request);
    eprintln!("{}", served.notice());
    let result = warm.unwrap_or_else(|| check_write(manifest, request));
    // Applied here, after either path, so warm/cold parity holds for the
    // read-only gate exactly as it does for the pipeline rules.
    let result = super::gate_read_only(manifest, result, &request.file_path);
    // F75 — same placement, same reason: the nested-manifest rule is a
    // write-time verdict and must be identical warm and cold.
    super::gate_nested_manifest(result, &request.file_path)
}

fn warm_check(request: &WriteRequest) -> (Option<CheckResult>, ServedBy) {
    let root = Path::new(".");
    match mode() {
        Mode::Off => (None, ServedBy::ColdDisabled),
        Mode::Probe => warm_attempt(root, request),
        Mode::Auto => auto_attempt(root, request),
    }
}

/// One warm request, classified. `NotRunning` covers both "no socket" and
/// "nothing listening on it" — `request_at` maps them together, and telling
/// them apart would mean changing the transport, which is out of scope here.
fn warm_attempt(root: &Path, request: &WriteRequest) -> (Option<CheckResult>, ServedBy) {
    match warm_request(root, request) {
        Ok(result) => (Some(result), ServedBy::Warm),
        Err(ServerError::NotRunning) => (None, ServedBy::ColdNoDaemon),
        Err(error) => (None, ServedBy::ColdWarmFailed(error.to_string())),
    }
}

fn auto_attempt(root: &Path, request: &WriteRequest) -> (Option<CheckResult>, ServedBy) {
    match warm_attempt(root, request) {
        (None, ServedBy::ColdNoDaemon) => match autostart(root) {
            Ok(()) => warm_attempt(root, request),
            Err(error) => (
                None,
                ServedBy::ColdWarmFailed(format!("autostart failed: {error}")),
            ),
        },
        other => other,
    }
}

fn warm_request(root: &Path, request: &WriteRequest) -> Result<CheckResult, ServerError> {
    let response = server::request(
        root,
        &Request::Check {
            v: PROTOCOL_VERSION,
            file_path: request.file_path.clone(),
            content: request.content.clone(),
        },
    )?;
    match response {
        Response::Check { result } => Ok(result),
        other => Err(ServerError::Protocol(format!(
            "unexpected response to a check: {other:?}"
        ))),
    }
}

/// Spawn `pushkin daemon serve` detached and wait for its socket. The
/// child intentionally outlives this shim process (fire-and-forget by
/// design: the daemon IS the long-lived side); stdio is nulled so it can
/// never corrupt a hook's stdout verdict channel.
fn autostart(root: &Path) -> std::io::Result<()> {
    let exe = std::env::current_exe()?;
    // FACADE-EXEMPT: self-exec of the canonical pushkin binary (daemon serve),
    // not a vendored external tool (charter 2026-08-20-r1-facades §3c).
    std::process::Command::new(exe)
        .args(["daemon", "serve"])
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()?;

    let socket = server::socket_path(root);
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
    while std::time::Instant::now() < deadline {
        if socket.exists() {
            return Ok(());
        }
        std::thread::sleep(std::time::Duration::from_millis(10));
    }
    Err(std::io::Error::new(
        std::io::ErrorKind::TimedOut,
        "daemon socket did not appear within 2s",
    ))
}

/// `pushkin daemon serve`: run the daemon in the foreground until a
/// protocol Shutdown arrives. Shims spawn this detached in `auto` mode.
/// Startup repeats the doctor sweep (spec §6: config drift is a
/// continuously repaired condition) — findings go to stderr where a
/// foreground operator or log collector sees them; a drifted hook must
/// never stop the daemon from serving.
pub fn run_serve() -> Result<i32> {
    let manifest = super::load_manifest()?;
    startup_sweep();
    startup_regen();
    // The governing manifest is resolved HERE, not in the daemon: resolution
    // is the CLI's job (F73 phase 2) and the daemon must not grow a second
    // answer to "which manifest". Only this file reloads (F73 phase 3).
    let governing = super::manifest_path()?;
    server::serve_resolved(
        &server::socket_path(Path::new(".")),
        manifest,
        false,
        Some(&governing),
    )?;
    Ok(0)
}

/// Doctor's health line for the canonical daemon (INFO, never a finding:
/// the cold path is a fully correct gate, so a stopped daemon must not
/// change doctor's exit code).
#[must_use]
pub fn health_line() -> String {
    match ping(Path::new(".")) {
        Some(info) => format!("daemon: running (pid {})", info.pid),
        None => "daemon: not running (warm path off; cold checks remain in force)".to_owned(),
    }
}

fn startup_sweep() {
    for finding in super::doctor::sweep_findings() {
        eprintln!("pushkin daemon: doctor: {finding}");
    }
}

/// The §5.2 eager pass: probe generated/ headers against the manifest's
/// `schema_epoch` (R9: the manifest is the sole epoch source), regenerate
/// stale artifacts sequentially (60s per item — a stuck toolchain item is
/// reported and skipped, never a wedged queue). Absence of generated/ is
/// not an error: pre-compile repos serve fine.
fn startup_regen() {
    let generated = Path::new("generated");
    if !generated.is_dir() {
        return;
    }
    // The queue worker needs 'static anyway: load the owned manifest up
    // front — it also carries the epoch the probe compares against.
    let Ok(owned_manifest) = super::load_manifest() else {
        eprintln!("pushkin daemon: epoch probe skipped: manifest failed to load");
        return;
    };
    let stale = match pushkin_daemon::regen::probe_stale(generated, owned_manifest.schema_epoch) {
        Ok(stale) => stale,
        Err(error) => {
            eprintln!("pushkin daemon: epoch probe failed: {error}");
            return;
        }
    };
    if stale.is_empty() {
        return;
    }
    for path in &stale {
        eprintln!(
            "pushkin daemon: stale epoch: generated/{} queued for regeneration",
            path.display()
        );
    }
    let outcomes =
        pushkin_daemon::regen::run_queue(&stale, std::time::Duration::from_mins(1), move |item| {
            let name = item.to_string_lossy();
            super::compile::regenerate_one(&owned_manifest, &name)
        });
    for (path, outcome) in outcomes {
        use pushkin_daemon::regen::RegenOutcome;
        match outcome {
            RegenOutcome::Regenerated => {
                eprintln!("pushkin daemon: regenerated generated/{}", path.display());
            }
            RegenOutcome::TimedOut => {
                eprintln!(
                    "pushkin daemon: regeneration TIMED OUT for generated/{} (queue continued)",
                    path.display()
                );
            }
            RegenOutcome::Failed(reason) => {
                eprintln!(
                    "pushkin daemon: regeneration FAILED for generated/{}: {reason}",
                    path.display()
                );
            }
        }
    }
}

// ---------- lifecycle verbs + canonical-binary guard (spec §8.4) ----------

/// Where the canonical binary path is pinned (trust-on-first-use, the
/// consent pattern): the first `daemon start` writes it; every later
/// lifecycle verb must match it.
const CANONICAL_FILE: &str = ".pushkin/daemon.canonical";

fn current_exe_canonical() -> Result<String> {
    let exe = std::env::current_exe()?.canonicalize()?;
    Ok(exe.to_string_lossy().into_owned())
}

/// The §8.4 guard. `Ok(pinned_path)` when this binary may run lifecycle
/// verbs; `Err` carries the refusal message (which must offer the
/// read-only alternative — that offer is test-pinned).
fn guard() -> Result<String> {
    let me = current_exe_canonical()?;
    let pin_path = Path::new(CANONICAL_FILE);
    if !pin_path.exists() {
        if let Some(parent) = pin_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(pin_path, format!("{me}\n"))?;
        return Ok(me);
    }
    let pinned = std::fs::read_to_string(pin_path)?.trim().to_owned();
    if pinned == me {
        return Ok(me);
    }
    anyhow::bail!(
        "pushkin daemon: this binary ({me}) is not the canonical one pinned at first start \
         ({pinned}). Lifecycle verbs are refused for non-canonical copies (spec §8.4). \
         Use `pushkin daemon start --read-only` for a read-only daemon on a private socket, \
         or have a human update {CANONICAL_FILE}."
    )
}

/// `daemon start [--read-only]`. Canonical: spawn the detached server on
/// the shared socket. Read-only: open to ANY binary, private socket,
/// prints `socket: <path>` for the caller to target.
pub fn run_start(read_only: bool) -> Result<i32> {
    if read_only {
        return start_read_only();
    }
    if let Err(refusal) = guard() {
        eprintln!("{refusal}");
        return Ok(1);
    }
    let root = Path::new(".");
    if ping(root).is_some() {
        println!("pushkin daemon already running");
        return Ok(0);
    }
    match autostart(root) {
        Ok(()) => {
            println!("pushkin daemon started");
            Ok(0)
        }
        Err(error) => {
            eprintln!("pushkin daemon failed to start: {error}");
            Ok(1)
        }
    }
}

fn start_read_only() -> Result<i32> {
    let socket = PathBuf::from(format!(".pushkin/daemon-ro-{}.sock", std::process::id()));
    let exe = std::env::current_exe()?;
    // FACADE-EXEMPT: self-exec of the canonical pushkin binary (daemon serve
    // --read-only), not a vendored external tool (charter 2026-08-20-r1-facades §3c).
    std::process::Command::new(exe)
        .args(["daemon", "serve", "--read-only", "--socket"])
        .arg(&socket)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()?;
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
    while std::time::Instant::now() < deadline {
        if socket.exists() {
            // Absolute path: callers run from other cwds target it directly.
            let absolute = socket.canonicalize()?;
            println!("socket: {}", absolute.display());
            println!("read-only daemon started (kill its pid to stop it)");
            return Ok(0);
        }
        std::thread::sleep(std::time::Duration::from_millis(10));
    }
    eprintln!("read-only daemon socket did not appear within 2s");
    Ok(1)
}

/// `daemon stop`: guarded; a clean protocol shutdown.
#[must_use]
pub fn run_stop() -> i32 {
    if let Err(refusal) = guard() {
        eprintln!("{refusal}");
        return 1;
    }
    let root = Path::new(".");
    match server::request(
        root,
        &Request::Shutdown {
            v: PROTOCOL_VERSION,
        },
    ) {
        Ok(Response::ShuttingDown) => {
            // Shutdown is async on the daemon side; wait for the socket.
            let socket = server::socket_path(root);
            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
            while std::time::Instant::now() < deadline {
                if !socket.exists() {
                    break;
                }
                std::thread::sleep(std::time::Duration::from_millis(10));
            }
            println!("pushkin daemon stopped");
            0
        }
        Err(ServerError::NotRunning) => {
            println!("pushkin daemon not running");
            0
        }
        other => {
            eprintln!("pushkin daemon stop failed: {other:?}");
            1
        }
    }
}

/// `daemon restart`: guarded; stop-if-running then start. A stale socket
/// file (unclean exit) is handled by `serve()`'s bind-time recovery.
#[must_use]
pub fn run_restart() -> i32 {
    if let Err(refusal) = guard() {
        eprintln!("{refusal}");
        return 1;
    }
    let root = Path::new(".");
    if ping(root).is_some() {
        let code = run_stop();
        if code != 0 {
            return code;
        }
    } else {
        // No live daemon; clear any stale socket so start binds cleanly.
        let _ = std::fs::remove_file(server::socket_path(root));
    }
    match autostart(root) {
        Ok(()) => {
            println!("pushkin daemon restarted");
            0
        }
        Err(error) => {
            eprintln!("pushkin daemon failed to restart: {error}");
            1
        }
    }
}

/// `daemon status`: a question, not a mutation — no guard. Exit 0 when a
/// daemon answers, 1 otherwise (doctor-style unhealthy).
#[must_use]
pub fn run_status() -> i32 {
    let root = Path::new(".");
    if let Some(info) = ping(root) {
        println!(
            "pushkin daemon running\n  pid: {}\n  version: {}\n  socket: {}\n  read-only: {}",
            info.pid,
            info.version,
            server::socket_path(root).display(),
            info.read_only,
        );
        0
    } else {
        println!("pushkin daemon not running");
        1
    }
}

fn ping(root: &Path) -> Option<pushkin_daemon::protocol::DaemonInfo> {
    match server::request(
        root,
        &Request::Ping {
            v: PROTOCOL_VERSION,
        },
    ) {
        Ok(Response::Pong { info }) => Some(info),
        _ => None,
    }
}

/// `pushkin daemon serve --read-only --socket <path>`: foreground server
/// on an explicit private socket.
pub fn run_serve_at(socket: &Path, read_only: bool) -> Result<i32> {
    let manifest = super::load_manifest()?;
    let governing = super::manifest_path()?;
    server::serve_resolved(socket, manifest, read_only, Some(&governing))?;
    Ok(0)
}