vivac 0.15.8

Provenance tree for work: every node knows which node it was born from
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
//! The three session hooks. `ROADMAP.md` ยง4.
//!
//! `session start` injects the brief and `session end` leaves an automatic
//! stop. They are the **seams of the session**, the way `push`/`pop` are the
//! seams of the work: they ask for no judgement of relevance, they just happen.
//!
//! `session prompt` (`d779`) is the third: a nudge, on every message, for the
//! long stretch between those two seams where the thread can still go cold.
//! It reads the log and never writes to it, and it never fails the turn --
//! see [`prompt`]'s own doc for the whole of that promise.
//!
//! `start` and `end` exit 0 and say nothing when there is no `.vivac/`. A
//! hook that fails in every directory without a tree gets switched off
//! within two days.

use crate::args::Args;
use crate::event::{Body, VivacKind};
use crate::failure::{Failure, R};
use crate::output::outln;

/// What the hook is handed on stdin.
///
/// Two fields are read and the rest is left where it is. `transcript_path`
/// travels in this same payload and it is the tempting one --it is what really
/// links the tree to the conversation-- but it carries the user's home
/// directory, and the security pillar vetoes that without negotiation. An
/// opaque identifier yes; a path into somebody's filesystem no.
struct HookInput {
    source: String,
    session: Option<String>,
}

/// The payload a harness writes to a hook's standard input, read whole once
/// and kept. `main` calls this before anything else for every `session ...
/// --hook`, including where there is no tree: a hook that exits without
/// reading leaves the harness writing into a closed pipe, which is the
/// broken pipe the test for "outside a tree" hit on a busy runner once
/// `session prompt` learned to return before touching its input.
pub fn hook_stdin() -> &'static str {
    static RAW: std::sync::OnceLock<String> = std::sync::OnceLock::new();
    RAW.get_or_init(|| {
        use std::io::IsTerminal;
        let mut raw = String::new();
        // A terminal has no payload to give, and reading one would hang the
        // hook waiting for an EOF that never comes.
        if !std::io::stdin().is_terminal() {
            use std::io::Read;
            std::io::stdin().read_to_string(&mut raw).ok();
        }
        raw
    })
}

impl HookInput {
    fn read() -> HookInput {
        let v: serde_json::Value =
            serde_json::from_str(hook_stdin()).unwrap_or(serde_json::Value::Null);
        HookInput {
            // `unknown` and not an empty string, so that reading it later tells
            // "it did not say" apart from "we did not look".
            source: v
                .get("source")
                .and_then(|s| s.as_str())
                .unwrap_or("unknown")
                .to_string(),
            session: v
                .get("session_id")
                .and_then(|s| s.as_str())
                .map(str::to_string),
        }
    }
}

pub fn start(ctx: &mut crate::ops::Ctx, a: &Args, project: &str) -> R {
    if !a.has("hook") {
        return crate::brief::brief(&ctx.tree, &ctx.store.root, &ctx.lane_dir, a, project);
    }
    // In hook mode the brief goes straight to stdout, in plain text: Claude
    // Code's own hook reference says plain-text stdout on `SessionStart`
    // becomes context the agent can see and act on (`f403`, `f404`), so there
    // is no envelope to build and no format only this one hook understands.
    let text = crate::brief::to_text(&ctx.tree, &ctx.store.root, &ctx.lane_dir, a, project, true)?;
    print!("{text}");
    // The brief goes out **first**, and the write cannot take it down. A
    // failure that left the agent with no brief would turn a hole in the
    // instrument into blindness in the product, which is a far worse trade: a
    // log missing an opening shows up on reading, an agent missing its brief
    // does not show up until the thread is already lost.
    let hook = HookInput::read();
    // The focus the brief paints is the top of the stack: it walks the
    // ancestors of `stack.last()` and keeps the last of the lineage, which is
    // that same node again.
    //
    // What the brief painted, taken before the lock: a writer that lands
    // while this one waits must not rewrite what the agent was shown.
    let shown_focus = ctx.tree.focus().map(|n| n.id.clone());
    // By lane, matching what `brief`/`to_text` just painted (`brief.rs`'s
    // own resume line reads `last_vivac()` too): the tree-wide
    // `vivacs.last()` used to record a stop this session never saw,
    // whenever another lane's stop happened to sit last in the log
    // (`t594`).
    let shown_vivac = ctx.tree.last_vivac().map(|v| v.id.clone());
    match ctx.lock_for_write() {
        Ok(mine) => {
            crate::ops::session_started(ctx, &hook.source, hook.session, shown_focus, shown_vivac)
                .ok();
            // The write is done; nothing after this needs the lock, and the
            // process outlives it. Only released if this call is the one
            // that took it (`f602`).
            if mine {
                ctx.unlock();
            }
        }
        // The brief is already out. A session another writer kept from
        // being recorded is a small hole in the log; say so where the
        // agent reads, and never let it cost the brief (`d598`).
        Err(Failure::Busy(_)) => {
            outln!(
                "  Session not recorded: another vivac process held the tree for {} seconds.",
                crate::store::LOCK_DEADLINE.as_secs()
            );
        }
        Err(_) => {}
    }
    Ok(())
}

pub fn end(ctx: &mut crate::ops::Ctx, a: &Args, located: &crate::store::Located) -> R {
    // `d787`'s turn clock closes here, ahead of every check below: the
    // turn happened whether or not this session leaves anything worth an
    // automatic stop.
    if a.has("hook") {
        close_turn(located, a);
    }
    // The cheap checks go first, against whatever this process already
    // loaded, so a turn with nothing to stop never asks for the lock at
    // all: a read-only tree or a filesystem with no lock support would
    // otherwise fail this hook on every ordinary turn instead of only on
    // the one that actually has something to close.
    if nothing_to_stop(&ctx.tree, a) {
        return Ok(());
    }
    // The decision whether anything changed has to be made on the tree on
    // disk. In hook mode any failure to take the lock -- busy, or the lock
    // itself unsupported -- is swallowed the same way: the change that
    // armed this stop is still there for the next turn, and nothing is
    // lost. Without a hook it is still reported, as before.
    let mine = match ctx.lock_for_write() {
        Ok(mine) => mine,
        Err(_) if a.has("hook") => return Ok(()),
        Err(e) => return Err(e),
    };
    // The lock may have reloaded the tree from disk, so the same cheap
    // checks are repeated here against what is actually there now.
    if nothing_to_stop(&ctx.tree, a) {
        return Ok(());
    }
    let next = a.opt_or("next");
    let label = segment_label(&ctx.tree);
    let num = ctx.tree.next_vivac_num.max(1);
    crate::ops::auto_vivac(ctx, VivacKind::Auto, &next, &label)?;
    // The write is done; nothing after this needs the lock. Only released
    // if this call is the one that took it (`f602`).
    if mine {
        ctx.unlock();
    }
    if !a.has("hook") {
        outln!("  v{num}  automatic stop at session close");
    }
    Ok(())
}

/// Whether `t` has nothing worth an automatic stop: an empty stack, or
/// nothing new since the last one.
fn nothing_to_stop(t: &crate::model::Tree, a: &Args) -> bool {
    // With no stack there is no thread to close, and an empty vivac is just
    // noise to be pruned later.
    if t.stack().is_empty() {
        if !a.has("hook") {
            outln!("  Empty stack: no stop worth saving.");
        }
        return true;
    }
    // Nor with nothing new. Claude Code does have a `SessionEnd` event, but
    // the automatic stop hangs off `Stop` instead: `Stop` fires on every
    // turn, so the last stop never depends on the session closing cleanly
    // (`f568`). Without this guard it would be forty identical stops a day,
    // and a stop that repeats is not a stop, it is a log.
    if t.state().seq_change <= t.state().seq_vivac {
        if !a.has("hook") {
            outln!("  Nothing changed since the last stop.");
        }
        return true;
    }
    false
}

/// What the segment being closed contained, counted off the seams.
///
/// The other four kinds of stop are written by somebody who knows what they
/// were doing, and they all carry a `next_intent`. The automatic one is
/// written by a hook that was never told: asking the agent for the intent is
/// the judgement of relevance `DX` already measured at zero uses. So it
/// carries what it can know without asking --how much the segment held-- and
/// leaves `next_intent` honestly empty (`f59`).
fn segment_label(t: &crate::model::Tree) -> String {
    let s = t.state();
    let mut parts = Vec::new();
    if s.seg_new > 0 {
        parts.push(format!("{} new", s.seg_new));
    }
    if s.seg_closed > 0 {
        parts.push(format!("{} closed", s.seg_closed));
    }
    if s.seg_notes == 1 {
        parts.push("1 note".to_string());
    } else if s.seg_notes > 1 {
        parts.push(format!("{} notes", s.seg_notes));
    }
    if parts.is_empty() && s.seg_events > 0 {
        parts.push(if s.seg_events == 1 {
            "1 change".to_string()
        } else {
            format!("{} changes", s.seg_events)
        });
    }
    parts.join(", ")
}

pub fn dispatch(
    ctx: &mut crate::ops::Ctx,
    a: &Args,
    project: &str,
    located: &crate::store::Located,
) -> R {
    match a.positional(0) {
        Some("start") => start(ctx, a, project),
        Some("end") => end(ctx, a, located),
        // `prompt` is intercepted in `main.rs`, ahead of every tree lookup
        // this dispatch would otherwise make: `d779`'s whole point is that
        // it never fails the turn, and a `Ctx` that failed to load would
        // have already turned into a non-zero exit before reaching here.
        _ => Err(Failure::usage(
            "usage: vivac session start|end|prompt [--hook]",
        )),
    }
}

/// `d779`: the thread has to have gone quiet for a while before the nudge
/// is worth the tokens. `d787` changes what "quiet" measures: minutes the
/// agent spent working without a capture landing, never wall-clock minutes
/// since the last write -- the stretch a person takes to answer does not
/// count against it (`f786`). Active time already implies the session has
/// been open a while, so the wall-clock floor this used to sit beside,
/// `PROMPT_SESSION_MIN`, is gone with it.
const PROMPT_QUIET_MIN: i64 = 10;
const PROMPT_COOLDOWN_MIN: i64 = 10;

/// Whether `body` is one of the seams `brief.rs`'s own capture-seams block
/// names -- a fact this lane wrote about the *work*, not about the session
/// or the machinery around it.
///
/// `VivacCreated` counts only when it is `Manual`, a `save` a person sat
/// down and wrote: `Auto` is the `Stop` hook's own heartbeat, and `Push`,
/// `Pop` and `Park` ride along with an operation that already counts on its
/// own account. Counting any of those four here would let a session that
/// never wrote anything keep resetting its own clock by closing a turn.
fn is_capture(body: &Body) -> bool {
    match body {
        Body::NodeCreated { .. }
        | Body::StateChanged { .. }
        | Body::NodeNoted { .. }
        | Body::BlockChanged { .. }
        | Body::Pushed { .. }
        | Body::Popped { .. }
        | Body::Promoted { .. }
        | Body::FlagRaised { .. }
        | Body::FlagCleared { .. }
        | Body::ArmAdded { .. }
        | Body::ArmRemoved { .. }
        | Body::AgainstAdded { .. } => true,
        Body::VivacCreated { kind, .. } => *kind == VivacKind::Manual,
        Body::SessionStarted { .. }
        | Body::LaneDeclared { .. }
        | Body::LaneClaimed { .. }
        | Body::WhereChanged { .. } => false,
    }
}

/// How many events of the whole log are capture seams, across every lane:
/// `init --undo`'s own before-birth check (`d784`) asks whether a bare
/// plant has ever had any work land on it at all, not just this one
/// lane's share of it -- `lane.declared`, `lane.claimed`, `session.started`
/// and `where.changed` never count, the same as `is_capture` above.
pub(crate) fn capture_count(events: &[crate::event::Event]) -> usize {
    events.iter().filter(|e| is_capture(&e.payload)).count()
}

/// [`capture_count`]'s own question, narrowed to one lane: `f790`'s own
/// migrate advice, moved onto `setup`'s closing text, asks not whether the
/// tree has ever captured anything but whether *this* lane has -- a lane
/// that just joined a tree full of another lane's work still has nothing
/// of its own brought in yet.
pub(crate) fn lane_capture_count(events: &[crate::event::Event], lane: &str) -> usize {
    events
        .iter()
        .filter(|e| e.lane == lane && is_capture(&e.payload))
        .count()
}

/// The `ts` of the last event of this lane that `matches`, log order being
/// what `Store::read_all` already hands back: the last match in the vector
/// is the last one in time.
fn last_matching_ts<'a>(
    events: &'a [crate::event::Event],
    lane: &str,
    matches: impl Fn(&Body) -> bool,
) -> Option<&'a str> {
    events
        .iter()
        .rev()
        .find(|e| e.lane == lane && matches(&e.payload))
        .map(|e| e.ts.as_str())
}

/// Where the turn/cooldown state for `key` lives: a file under
/// `std::env::temp_dir()`, named from a hash rather than the key itself --
/// the key can carry a session identifier, and the security pillar keeps
/// that out of a filename as much as out of the log. `d787` grew what
/// lives here from a bare last-nudge integer to [`TurnState`], and the
/// `Stop` hook now writes here too, through the same key `state_key`
/// builds for both.
fn cooldown_path(key: &str) -> std::path::PathBuf {
    let hash = crate::setup::fnv1a64(key.as_bytes());
    std::env::temp_dir()
        .join("vivac")
        .join(format!("prompt-{hash:016x}"))
}

/// The bookkeeping `cooldown_path` names, one line, versioned:
/// `v1 <last_nudge_secs> <turn_start_secs> <active_secs> <reference_secs>`,
/// every field epoch seconds and `0` meaning none -- never warned, no turn
/// open, no work folded in yet, no reference point seen. `active_secs` is
/// `d787`'s own addition: minutes the agent spent working, folded in one
/// turn at a time by the `Stop` hook, never read off the clock alone.
///
/// A file this version cannot make sense of -- missing, unreadable, or
/// left by whatever came before `d787` and held a bare integer -- reads
/// back as all zeros rather than failing: `d779`'s own rule that a hook
/// with an opinion about its own bookkeeping is a hook that can block on
/// it.
#[derive(Default)]
struct TurnState {
    last_nudge_secs: i64,
    turn_start_secs: i64,
    active_secs: i64,
    reference_secs: i64,
}

impl TurnState {
    fn read(path: &std::path::Path) -> TurnState {
        let Ok(text) = std::fs::read_to_string(path) else {
            return TurnState::default();
        };
        let mut words = text.split_whitespace();
        if words.next() != Some("v1") {
            return TurnState::default();
        }
        let mut field = || words.next().and_then(|w| w.parse::<i64>().ok());
        match (field(), field(), field(), field()) {
            (
                Some(last_nudge_secs),
                Some(turn_start_secs),
                Some(active_secs),
                Some(reference_secs),
            ) => TurnState {
                last_nudge_secs,
                turn_start_secs,
                active_secs,
                reference_secs,
            },
            _ => TurnState::default(),
        }
    }

    /// Best effort: a failure here costs the next read one lost update,
    /// never the turn itself.
    fn write(&self, path: &std::path::Path) {
        if let Some(dir) = path.parent() {
            std::fs::create_dir_all(dir).ok();
        }
        std::fs::write(
            path,
            format!(
                "v1 {} {} {} {}",
                self.last_nudge_secs, self.turn_start_secs, self.active_secs, self.reference_secs
            ),
        )
        .ok();
    }
}

/// The lane a `Located` resolves to, the one way every hook here reads it:
/// the lane file's own id, or the implicit `main` of a folder with none.
fn hook_lane(located: &crate::store::Located) -> String {
    located
        .lane
        .as_ref()
        .map(|l| l.id.clone())
        .unwrap_or_else(|| crate::lane::MAIN.to_string())
}

/// The turn/cooldown key `prompt` and the `Stop` hook both index their
/// state by, built the one way so the two can never resolve two different
/// files for what is really the same thread: the project's own opaque
/// first identifier, the lane, and the session id the harness sent on
/// stdin.
///
/// The key never carries the project's own path: `first_event_id` is the
/// log's own opaque first identifier, the same one the registry keys
/// projects by.
fn state_key(root: &std::path::Path, lane: &str, session: &Option<String>) -> String {
    let project_id = crate::store::first_event_id(root).unwrap_or_default();
    match session {
        Some(s) => format!("{project_id}\u{0}{lane}\u{0}{s}"),
        None => format!("{project_id}\u{0}{lane}"),
    }
}

/// The `Stop` hook's half of `d787`'s turn clock: folds the turn that is
/// closing into `active_secs`, and clears `turn_start_secs` so the next
/// prompt does not double it. Best effort and silent, and called ahead of
/// [`nothing_to_stop`]'s own early return -- the turn happened whether or
/// not it leaves anything worth an automatic stop. A turn with no `Stop`
/// at all -- an interrupted one -- just has its `turn_start_secs`
/// overwritten by the next prompt: it undercounts, which is the safe
/// direction.
fn close_turn(located: &crate::store::Located, a: &Args) {
    let lane = hook_lane(located);
    let session = HookInput::read().session;
    let key = state_key(&located.root, &lane, &session);
    let path = cooldown_path(&key);
    let mut state = TurnState::read(&path);
    if state.turn_start_secs <= 0 {
        return;
    }
    let now = a
        .opt("now")
        .map(str::to_string)
        .unwrap_or_else(crate::clock::now_rfc3339);
    let Some(now_secs) = crate::clock::epoch_seconds(&now) else {
        return;
    };
    let worked_secs = (now_secs - state.turn_start_secs).max(0);
    state.active_secs += worked_secs;
    state.turn_start_secs = 0;
    state.write(&path);
}

/// Whether the last nudge this key saw, `last_nudge_secs` (`0` meaning
/// never), was more than `PROMPT_COOLDOWN_MIN` ago.
fn cooled_down(last_nudge_secs: i64, now_secs: i64) -> bool {
    last_nudge_secs == 0 || now_secs - last_nudge_secs >= PROMPT_COOLDOWN_MIN * 60
}

/// The text `prompt` prints when it decides to speak, `n` being the whole
/// minutes the agent has worked, across turns, since the reference point
/// -- session start, or a capture since, whichever is more recent -- with
/// the minutes a person spent answering left out (`d787`, `f786`).
fn prompt_text(n: i64) -> String {
    format!(
        "vivac: nothing written to the tree in {n} min of work in this session. If a seam\n\
         passed since (a new line of work, a choice, a finding you told, a \"not now\", \
         work done, a change outside the repo), write it now, before you answer.\n"
    )
}

/// `session prompt --hook` (`d779`): a nudge for the stretch between the two
/// boundaries `start` and `end` already cover, run on every message a person
/// sends. **Always exits 0 and never writes to the log.** Anything that
/// keeps this from answering cleanly -- no tree, a log this version cannot
/// read, garbage on stdin, a `.vivac/lane` this process cannot resolve --
/// reads exactly like nothing worth saying: empty stdout, exit 0. A hook
/// that can fail the turn it rides on is worse than one that occasionally
/// stays quiet when it had something to say.
///
/// The two seams already write a trace of their own kind -- `session
/// started`, an automatic stop -- so this is the one hook of the three that
/// is pure: it reads what the other two, and every ordinary write, already
/// left behind, and decides without touching any of it.
pub fn prompt(cwd: &std::path::Path, a: &Args) {
    let Some(text) = prompt_text_for(cwd, a) else {
        return;
    };
    print!("{text}");
}

/// [`prompt`]'s own decision, factored out so every early exit is a plain
/// `?` rather than a chain of nested matches: any `None` here is "nothing
/// to say", never a reason to report failure upward.
fn prompt_text_for(cwd: &std::path::Path, a: &Args) -> Option<String> {
    let located = crate::store::locate(cwd).ok()??;
    let store = crate::store::Store::open(located.root.clone()).ok()?;
    let (events, _broken) = store.read_all().ok()?;
    let lane = hook_lane(&located);

    let session_start =
        last_matching_ts(&events, &lane, |b| matches!(b, Body::SessionStarted { .. }))?;
    let last_capture = last_matching_ts(&events, &lane, is_capture);

    let now = a
        .opt("now")
        .map(str::to_string)
        .unwrap_or_else(crate::clock::now_rfc3339);
    let now_secs = crate::clock::epoch_seconds(&now)?;
    let session_start_secs = crate::clock::epoch_seconds(session_start)?;

    let reference_secs = match last_capture.and_then(crate::clock::epoch_seconds) {
        Some(c_secs) if c_secs > session_start_secs => c_secs,
        _ => session_start_secs,
    };

    let session = HookInput::read().session;
    let key = state_key(&located.root, &lane, &session);
    let path = cooldown_path(&key);
    let mut state = TurnState::read(&path);

    // A newer reference point than the one this state was last built
    // against: work already landed since, so the clock the agent's own
    // turns had been filling starts over at zero.
    if state.reference_secs != reference_secs {
        state.active_secs = 0;
        state.reference_secs = reference_secs;
    }

    let active_min = state.active_secs / 60;
    let speaks = active_min >= PROMPT_QUIET_MIN && cooled_down(state.last_nudge_secs, now_secs);

    // A turn opens here regardless of the decision above: the `Stop` hook
    // is what closes it, and this is always written -- `turn_start_secs`
    // changed even where nothing else did.
    state.turn_start_secs = now_secs;
    if speaks {
        state.last_nudge_secs = now_secs;
    }
    state.write(&path);

    speaks.then(|| prompt_text(active_min))
}