mod anchor;
mod args;
mod brief;
mod changes;
mod check;
mod clock;
mod event;
mod failure;
mod glob;
mod id;
mod import;
mod index;
mod lane;
mod mcp;
mod model;
mod ops;
mod outcome;
mod output;
mod params;
mod project;
mod reconcile;
mod redact;
mod registry;
mod relocate;
mod render;
mod repos;
mod session;
mod setup;
mod store;
mod web;
use args::Args;
use failure::Failure;
use output::outln;
const USAGE: &str = r#"vivac - provenance of work
The agent writes (the stack carries the tree on its own)
vivac focus <id> [--reopen] step back into a node
vivac push "<title>" --why "<reason>" open a node and stack it
[--type goal|task|decision|question|constraint|finding|assumption
|pillar|rule]
[--blocks] its parent cannot close until this one closes
[--parent N] under N and not the focus; the stack goes to N
[--root] born at the root; the stack keeps only it
[--ref R] [--governs G]
[--arm "<command>"] what verifies a rule; vivac never runs it
[--arm-dir <dir>] where it runs, relative to where .vivac lives
[--against "r12: <why>"] on a decision: what it was judged against
vivac pop ["<outcome>"] [--next "<...>"] close the focus, back to the parent
vivac park [<id>] ["<reason>"] park it: feeds DO NOT TOUCH NOW
vivac promote [<id>] the focus becomes a goal of its own
vivac abandon [<id>] ["<reason>"] [--cascade]
[--rescue <id>] saves it and its own; it still hangs where it
was born, nothing is reparented
Without touching the stack
vivac add "<title>" [--parent N | --root] [--why "<reason>"] [--blocks]
[--type goal|task|decision|question|constraint|finding|assumption
|pillar|rule]
[--ref R] [--governs G]
[--arm "<command>"] what verifies a rule; vivac never runs it
[--arm-dir <dir>] where it runs, relative to where .vivac lives
[--against "r12: <why>"] on a decision: what it was judged against
vivac done <id> ["<outcome>"] [--force]
vivac note [<id>] "<note>"
vivac block <id> [--off]
vivac arm <rule> "<command>" --dir <dir> [--off]
vivac declare <decision> --against "r12: <why>"
vivac decide "<title>" --reason "<r>" [--parent N | --root]
[--alternative X] [--supersedes d9] [--blocks]
[--ref R] [--governs G]
[--against "r12: <why>"] what it was judged against; repeat it
vivac flag <id> suspect|review|stale --why "<reason>" [--off]
Safe stops
vivac save ["<label>"] [--next "<what you were about to do>"]
vivac restore <v> rebuilds the stack, gives the diff
vivac vivacs the stops, latest first
The maintainer reads (--json on all of them but the brief)
vivac brief [--budget 1500] [--now <date>]
where you are and what NOT to touch
vivac why <id> [--full] WHY WE ARE HERE
--full: anchor, standing
decisions and open siblings,
per step of the path
vivac tree [id] [--all] the tree, with false closes marked
vivac open [--all] open fronts and their lineage
vivac find "<text>" [--everywhere] every node whose words match
--everywhere: every project
the registry knows
vivac stack [--lanes] where you are right now
--lanes: every folder of this
product, and what it is on
vivac parked DO NOT TOUCH NOW
vivac rules the pillars, rules and invariants
that govern this project
vivac triage what can be pruned, and with what
vivac reconcile [--since <v>] [--all] files that changed with nothing
in the tree claiming them
vivac changes [--since <v>|manual] what moved since a stop, or
since the last one you made
vivac stats numbers
vivac check [--gates] invariants; belongs in CI
--gates also every tree on this machine that nobody opens
Session
vivac session start [--hook] the brief, ready to inject
vivac session end [--hook] automatic stop at close
vivac mcp serve the tree over MCP
vivac web [--port N] [--no-open] the tree in a browser, and
[--project P] nowhere but this machine
Getting started
vivac init [--dry-run] [--yes] [--undo] [--lane-name <name>]
plant .vivac/ here, or answer
which tree this folder belongs to
[--name <name>] the product's name, instead of the folder's;
only when init plants a tree
vivac init --join <name|path> [--lane-name <name>]
join this folder to a tree that
lives somewhere else
vivac init --new-tree plant here even if this
folder's repositories already
belong to a tracked product
vivac setup claude-code [--dry-run] [--yes] [--undo]
write what Claude Code needs here:
hooks, the MCP server, a skill
vivac setup codex [--dry-run] [--yes] [--undo]
write what Codex needs here:
hooks, the MCP server, a skill.
Every flag above means the same
vivac relocate <destination> [--lane-name <name>]
move the tree there, run from the
folder that holds it; this one
stays a lane of it, with its own
thread
vivac import <tree.json> bring in a tree from the spike
Exit codes
0 fine 1 the model refuses 2 usage 3 redaction guard 4 no .vivac
5 input/output error, a tree written by a newer vivac, or a tree
another process kept locked
"#;
static LATE_SIGHTING: std::sync::OnceLock<(
std::path::PathBuf,
Option<(String, std::path::PathBuf)>,
)> = std::sync::OnceLock::new();
fn main() {
let code = run();
output::flush();
note_late();
registry::warn_if_wrote();
std::process::exit(code);
}
fn note_late() {
let (Some((root, lane)), Some(store_dir)) = (LATE_SIGHTING.get(), store::store_dir()) else {
return;
};
if let Some(project_id) = store::first_event_id(root) {
let noted = registry::note(
&store_dir,
&project_id,
registry::Sighting {
root,
lane: lane.as_ref().map(|(id, dir)| (id.as_str(), dir.as_path())),
repos: None,
},
);
registry::set_pending(noted);
}
}
fn run() -> i32 {
let argv: Vec<String> = std::env::args().skip(1).collect();
let Some(cmd) = argv.first().cloned() else {
print!("{USAGE}");
return 0;
};
if matches!(cmd.as_str(), "-h" | "--help" | "help") {
print!("{USAGE}");
return 0;
}
if matches!(cmd.as_str(), "-V" | "--version" | "version") {
outln!("vivac {}", env!("CARGO_PKG_VERSION"));
return 0;
}
let a = match Args::parse(argv.into_iter().skip(1)) {
Ok(a) => a,
Err(e) => {
let c = e.code();
output::flush();
e.print_to_stderr();
return c;
}
};
match dispatch(&cmd, &a) {
Ok(code) => code,
Err(e) => {
let c = e.code();
output::flush();
e.print_to_stderr();
c
}
}
}
fn project_name(ctx: &ops::Ctx) -> String {
render::project_name(&ctx.store.root)
}
fn dispatch(cmd: &str, a: &Args) -> Result<i32, Failure> {
let cwd = std::env::current_dir().map_err(Failure::Io)?;
let allowed: &[&str] = match cmd {
"push" => &[
"why", "type", "blocks", "root", "parent", "ref", "governs", "arm", "arm-dir",
"against",
],
"pop" => &["force", "next"],
"decide" => &[
"parent",
"reason",
"alternative",
"supersedes",
"ref",
"governs",
"blocks",
"against",
"root",
],
"flag" => &["why", "off"],
"save" => &["next"],
"brief" => &["budget", "now"],
"session" => &["hook", "next", "budget", "now"],
"add" => &[
"parent", "why", "type", "blocks", "ref", "governs", "arm", "arm-dir", "against",
"root",
],
"done" => &["force"],
"abandon" => &["cascade", "rescue"],
"focus" => &["reopen"],
"block" => &["off"],
"arm" => &["dir", "off"],
"declare" => &["against"],
"tree" => &["all", "json"],
"reconcile" => &["since", "all", "json"],
"changes" => &["since", "json"],
"web" => &["port", "no-open", "project"],
"hooks" | "mcp" => &[],
"init" | "setup" => &[
"dry-run",
"yes",
"undo",
"join",
"new-tree",
"lane-name",
"name",
],
"relocate" => &["lane-name"],
"open" => &["json", "all"],
"stack" => &["json", "lanes"],
"parked" | "triage" | "stats" | "vivacs" | "rules" => &["json"],
"check" => &["json", "gates"],
"why" => &["json", "full", "project"],
"find" => &["json", "everywhere"],
"park" | "promote" | "note" | "import" | "restore" => &[],
_ => &[],
};
let unknown = a.unknown(allowed);
if !unknown.is_empty() {
let takes = if allowed.is_empty() {
"none".to_string()
} else {
allowed
.iter()
.map(|o| format!("--{o}"))
.collect::<Vec<_>>()
.join(" ")
};
return Err(Failure::usage(format!(
"{} does not take {}.
It takes: {takes}",
cmd,
unknown
.iter()
.map(|o| format!("--{o}"))
.collect::<Vec<_>>()
.join(" ")
)));
}
if cmd == "init" {
if let Some(lane) = lane::read(&cwd.join(store::DIR))? {
let is_own_tree = store::already_planted(&cwd)
&& store::first_event_id(&cwd).as_deref() == Some(lane.project.as_str());
if !is_own_tree && matches!(store::locate(&cwd), Err(Failure::TreeNotFound(_))) {
return Err(Failure::already_a_lane());
}
}
return setup::init(&cwd, a);
}
if cmd == "hooks" {
return Err(Failure::usage(
"vivac hooks is gone: vivac setup claude-code writes the hooks itself,\n \
after showing them.",
));
}
if cmd == "setup" {
return setup::dispatch(&cwd, a);
}
if cmd == "find" && a.has("everywhere") {
return render::find_everywhere(a).map(|_| 0);
}
if cmd == "web" {
let explicit: Vec<std::path::PathBuf> = a
.list("project")
.iter()
.map(std::path::PathBuf::from)
.collect();
let located_here = store::locate(&cwd)?;
let cwd_root = located_here.as_ref().map(|l| l.root.clone());
let roots = if explicit.is_empty() {
let mut from_registry = store::store_dir()
.map(|d| registry::roots(&d))
.unwrap_or_default();
from_registry.extend(cwd_root.clone());
from_registry
} else {
explicit
};
let port =
match a.opt("port") {
None => None,
Some(p) => Some(p.parse::<u16>().map_err(|_| {
Failure::usage(format!("--port needs a port number, not \"{p}\""))
})?),
};
return web::serve(roots, located_here, port, !a.has("no-open")).map(|_| 0);
}
let Some(located) = store::locate(&cwd)? else {
if cmd == "session" && a.has("hook") {
return Ok(0);
}
return Err(Failure::NoStore);
};
let root = located.root.clone();
if let Some(store_dir) = store::store_dir() {
let lane = located
.lane
.as_ref()
.map(|l| (l.id.clone(), located.lane_dir.clone()));
match store::first_event_id(&root) {
Some(project_id) => {
let noted = registry::note(
&store_dir,
&project_id,
registry::Sighting {
root: &root,
lane: lane.as_ref().map(|(id, dir)| (id.as_str(), dir.as_path())),
repos: None,
},
);
registry::set_pending(noted);
}
None => {
let _ = LATE_SIGHTING.set((root.clone(), lane));
}
}
}
if cmd == "relocate" {
if let [first, ..] = a.extra(1) {
return Err(Failure::usage(format!(
"{cmd} does not take \"{first}\".
It takes one word of its own. Everything else goes behind a --flag, and a flag
that repeats is written out again: --governs a --governs b"
)));
}
let destination = match a.positional(0) {
Some(d) if !d.is_empty() => d,
_ => {
return Err(Failure::usage(
"relocate needs a destination: vivac relocate <destination>",
))
}
};
return relocate::run(
&located,
std::path::Path::new(destination),
a.opt("lane-name"),
&cwd,
);
}
if cmd == "mcp" {
return mcp::serve(root, Some(located)).map(|_| 0);
}
if cmd == "changes" {
let (ctx, log) =
ops::Ctx::load_with_log(store::Store::open(root)?, ops::Whose::Resolved(&located))?;
return changes::changes(&ctx.tree, &log, a);
}
if cmd == "why" {
let extra_word = |a: &Args| -> Result<(), Failure> {
if let [first, ..] = a.extra(1) {
return Err(Failure::usage(format!(
"{cmd} does not take \"{first}\".
It takes one word of its own. Everything else goes behind a --flag, and a flag
that repeats is written out again: --governs a --governs b"
)));
}
Ok(())
};
if let Some(spec) = a.opt("project") {
let foreign_root = registry::resolve(spec)?;
let tree = index::load(&store::Store::open(foreign_root)?, false)?;
extra_word(a)?;
if a.has("full") {
return Err(Failure::usage(
"why --project does not take --full: --full reads the whole log, \
and a foreign project's log is never read that way.
Drop --full or drop --project."
.to_string(),
));
}
return render::why(&tree, &[], a).map(|_| 0);
}
if a.has("full") {
let (ctx, log) =
ops::Ctx::load_with_log(store::Store::open(root)?, ops::Whose::Resolved(&located))?;
extra_word(a)?;
return render::why(&ctx.tree, &log, a).map(|_| 0);
}
let ctx = ops::Ctx::load(store::Store::open(root)?, ops::Whose::Resolved(&located))?;
extra_word(a)?;
return render::why(&ctx.tree, &[], a).map(|_| 0);
}
let mut ctx = if may_append(cmd) {
ops::Ctx::load_for_write(store::Store::open(root)?, ops::Whose::Resolved(&located))?
} else {
ops::Ctx::load(store::Store::open(root)?, ops::Whose::Resolved(&located))?
};
if cmd == "check" {
return check::check(&ctx.tree, &ctx.store.root, a);
}
let takes: usize = match cmd {
"park" | "abandon" | "done" | "note" | "flag" | "arm" => 2,
"focus" | "push" | "pop" | "promote" | "add" | "block" | "decide" | "save" | "restore"
| "import" | "tree" | "session" | "find" | "declare" => 1,
_ => 0,
};
if let [first, ..] = a.extra(takes) {
let room = match takes {
0 => "no words of its own".to_string(),
1 => "one word of its own".to_string(),
n => format!("{n} words of its own"),
};
return Err(Failure::usage(format!(
"{cmd} does not take \"{first}\".
It takes {room}. Everything else goes behind a --flag, and a flag
that repeats is written out again: --governs a --governs b"
)));
}
if may_append(cmd) && !matches!(cmd, "session" | "restore") {
ctx.lock_for_write()?;
}
if let Some(o) = write_op(cmd, &mut ctx, a)? {
print!("{}", outcome::to_text(&o));
if matches!(cmd, "focus" | "restore") {
render::stack(&ctx.tree, &ctx.store.root, a)?;
}
return Ok(0);
}
let r: failure::R = match cmd {
"import" => import::import(&mut ctx, a),
"brief" => {
let project = project_name(&ctx);
brief::brief(&ctx.tree, &ctx.store.root, &ctx.lane_dir, a, &project)
}
"vivacs" => render::vivacs(&ctx.tree, a),
"session" => {
let project = project_name(&ctx);
session::dispatch(&mut ctx, a, &project)
}
"tree" => render::tree(&ctx.tree, a),
"open" => render::open(&ctx.tree, a),
"rules" => render::rules(&ctx.tree, a),
"find" => render::find(&ctx.tree, a),
"stack" => render::stack(&ctx.tree, &ctx.store.root, a),
"parked" => render::parked(&ctx.tree, a),
"triage" => render::triage(&ctx.tree, a),
"reconcile" => reconcile::reconcile(&ctx.tree, &ctx.store.root, &ctx.lane_dir, a),
"stats" => render::stats(&ctx.tree, a),
other => {
print!("{USAGE}");
return Err(Failure::usage(format!("unknown command: {other}")));
}
};
r.map(|_| 0)
}
fn may_append(cmd: &str) -> bool {
matches!(
cmd,
"push"
| "pop"
| "done"
| "park"
| "add"
| "note"
| "block"
| "arm"
| "promote"
| "abandon"
| "focus"
| "flag"
| "decide"
| "declare"
| "save"
| "restore"
| "session"
| "import"
)
}
fn write_op(cmd: &str, ctx: &mut ops::Ctx, a: &Args) -> Result<Option<outcome::Outcome>, Failure> {
Ok(Some(match cmd {
"push" => ops::push(ctx, params::Push::from_args(a)?)?,
"pop" => ops::pop(ctx, params::Pop::from_args(a)?)?,
"done" => ops::done(ctx, params::Done::from_args(a)?)?,
"park" => ops::park(ctx, params::Park::from_args(a)?)?,
"add" => ops::add(ctx, params::Add::from_args(a)?)?,
"note" => ops::note(ctx, params::Note::from_args(a)?)?,
"block" => ops::block(ctx, params::Block::from_args(a)?)?,
"arm" => ops::arm(ctx, params::Arm::from_args(a)?)?,
"promote" => ops::promote(ctx, params::Promote::from_args(a)?)?,
"abandon" => ops::abandon(ctx, params::Abandon::from_args(a)?)?,
"focus" => ops::focus(ctx, params::Focus::from_args(a)?)?,
"flag" => ops::flag(ctx, params::Flag::from_args(a)?)?,
"decide" => ops::decide(ctx, params::Decide::from_args(a)?)?,
"declare" => ops::declare(ctx, params::Declare::from_args(a)?)?,
"save" => ops::save(ctx, params::Save::from_args(a)?)?,
"restore" => ops::restore(ctx, params::Restore::from_args(a)?)?,
_ => return Ok(None),
}))
}
#[cfg(test)]
mod tests {
use super::USAGE;
use crate::failure::Failure;
use crate::redact;
use std::collections::BTreeSet;
fn exit_codes_in_usage(help: &str) -> BTreeSet<i32> {
let block = help
.split_once("Exit codes")
.expect("USAGE lost its `Exit codes` section")
.1;
block
.split_whitespace()
.filter_map(|word| word.parse().ok())
.collect()
}
fn exit_codes_failure_can_return() -> BTreeSet<i32> {
let finding = redact::check_field("token", "ghp_16C7e42F292c6912E7710c838347Ae178B4a")
.expect("a known credential prefix, refused by redact.rs's own tests too");
let variants = [
Failure::Model("the parent still has an open blocker".into()),
Failure::usage("unknown command: bogus"),
Failure::Redaction(Box::new(finding)),
Failure::NoStore,
Failure::SetupNoTree,
Failure::Io(std::io::Error::other("disk full")),
Failure::newer_vivac("this log holds an event this version does not know"),
Failure::busy(std::time::Duration::from_secs(5)),
Failure::not_a_lane(),
Failure::tree_not_found(),
];
variants
.into_iter()
.map(|f| match &f {
Failure::Model(_) => f.code(),
Failure::Usage(_) => f.code(),
Failure::Redaction(_) => f.code(),
Failure::NoStore => f.code(),
Failure::SetupNoTree => f.code(),
Failure::Io(_) => f.code(),
Failure::NewerVivac(_) => f.code(),
Failure::Busy(_) => f.code(),
Failure::NotALane(_) => f.code(),
Failure::TreeNotFound(_) => f.code(),
})
.collect()
}
#[test]
fn usage_lists_every_exit_code_failure_can_return() {
let listed = exit_codes_in_usage(USAGE);
let mut real = exit_codes_failure_can_return();
real.insert(0);
assert_eq!(
listed, real,
"USAGE's `Exit codes` block and what `Failure::code` can return \
(plus 0, for success) have drifted."
);
}
}