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 mcp;
mod model;
mod ops;
mod outcome;
mod output;
mod params;
mod project;
mod reconcile;
mod redact;
mod registry;
mod render;
mod session;
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]
[--blocks] its parent cannot close until this one closes
[--ref R] [--governs G]
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] [--why "<reason>"] [--blocks]
[--type goal|task|decision|question|constraint|finding|assumption]
[--ref R] [--governs G]
vivac done <id> ["<outcome>"] [--force]
vivac note [<id>] "<note>"
vivac block <id> [--off]
vivac decide "<title>" --reason "<r>" [--parent N] [--alternative X]
[--supersedes d9] [--blocks] [--ref R] [--governs G]
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 open fronts and their lineage
vivac find "<text>" [--everywhere] every node whose words match
--everywhere: every project
the registry knows
vivac stack where you are right now
vivac parked DO NOT TOUCH NOW
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
vivac hooks what to paste into settings.json
Getting started
vivac init plant .vivac/ here
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
"#;
static LATE_ROOT: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
fn main() {
let code = run();
note_late();
output::flush();
std::process::exit(code);
}
fn note_late() {
let (Some(root), Some(store_dir)) = (LATE_ROOT.get(), store::store_dir()) else {
return;
};
if let Some(project_id) = store::first_event_id(root) {
registry::note(&store_dir, &project_id, root);
}
}
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 = Args::parse(argv.into_iter().skip(1));
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 {
ctx.store
.root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "-".into())
}
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", "ref", "governs"],
"pop" => &["force", "next"],
"decide" => &[
"parent",
"reason",
"alternative",
"supersedes",
"ref",
"governs",
"blocks",
],
"flag" => &["why", "off"],
"save" => &["next"],
"brief" => &["budget", "now"],
"session" => &["hook", "next", "budget", "now"],
"add" => &["parent", "why", "type", "blocks", "ref", "governs"],
"done" => &["force"],
"abandon" => &["cascade", "rescue"],
"focus" => &["reopen"],
"block" => &["off"],
"tree" => &["all", "json"],
"reconcile" => &["since", "all", "json"],
"changes" => &["since", "json"],
"web" => &["port", "no-open", "project"],
"init" | "hooks" | "mcp" => &[],
"open" | "stack" | "parked" | "triage" | "stats" | "vivacs" => &["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" {
let s = store::Store::create(&cwd)?;
outln!(" vivac planted in {}", cwd.display());
outln!(" project {}", s.config.project_id);
outln!();
outln!(" First node: vivac push \"<title>\" --why \"<reason>\"");
return Ok(0);
}
if cmd == "hooks" {
return session::hooks().map(|_| 0);
}
if cmd == "find" && a.has("everywhere") {
return render::find_everywhere(a).map(|_| 0);
}
let Some(root) = store::find_root(&cwd) else {
if cmd == "session" && a.has("hook") {
return Ok(0);
}
return Err(Failure::NoStore);
};
if let Some(store_dir) = store::store_dir() {
match store::first_event_id(&root) {
Some(project_id) => registry::note(&store_dir, &project_id, &root),
None => {
let _ = LATE_ROOT.set(root.clone());
}
}
}
if cmd == "mcp" {
return mcp::serve(root).map(|_| 0);
}
if cmd == "web" {
let mut roots: Vec<std::path::PathBuf> = a
.list("project")
.iter()
.map(std::path::PathBuf::from)
.collect();
if roots.is_empty() {
roots.push(root);
}
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, port, !a.has("no-open")).map(|_| 0);
}
if cmd == "changes" {
let (ctx, log) = ops::Ctx::load_with_log(store::Store::open(root)?)?;
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)?)?;
extra_word(a)?;
return render::why(&ctx.tree, &log, a).map(|_| 0);
}
let ctx = ops::Ctx::load(store::Store::open(root)?)?;
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)?)?
} else {
ops::Ctx::load(store::Store::open(root)?)?
};
if cmd == "check" {
return check::check(&ctx.tree, a);
}
let takes: usize = match cmd {
"park" | "abandon" | "done" | "note" | "flag" => 2,
"focus" | "push" | "pop" | "promote" | "add" | "block" | "decide" | "save" | "restore"
| "import" | "tree" | "session" | "find" => 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 let Some(o) = write_op(cmd, &mut ctx, a)? {
print!("{}", outcome::to_text(&o));
if matches!(cmd, "focus" | "restore") {
render::stack(&ctx.tree, 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.anchor.as_ref(), 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),
"find" => render::find(&ctx.tree, a),
"stack" => render::stack(&ctx.tree, a),
"parked" => render::parked(&ctx.tree, a),
"triage" => render::triage(&ctx.tree, a),
"reconcile" => reconcile::reconcile(&ctx.tree, ctx.anchor.as_ref(), 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"
| "promote"
| "abandon"
| "focus"
| "flag"
| "decide"
| "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)?)?,
"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)?)?,
"save" => ops::save(ctx, params::Save::from_args(a)?)?,
"restore" => ops::restore(ctx, params::Restore::from_args(a)?)?,
_ => return Ok(None),
}))
}