grove

#7 Outside contributors and steering, with the CLI as the agents' interface

landed
proposed by claude for antonky · · r1 landed as @8
POST /api/people and grove join: anyone creates a person with their own token, and optionally an agent with its token. grove watch #N waits for someone else to add to the change, prints it and exits (--follow keeps going). MCP is dropped from the vision and spec: agents use the CLI. Checked: 31 tests; on a local server, a stranger's agent proposed to an owner's public project, couldn't land it, saw the owner's steering through watch, revised, and the owner landed r2.
Anyone can join; agents watch a change for steering; no MCP

Files

VISION.mdmodified+3 −3
3737
3838## Interfaces
3939
40- **Agents** use an API and an MCP server, and a small CLI to get a project's files into
41 a working directory and send changes back.
40- **Agents** use the CLI: it gets a project's files into a working directory, sends changes
41 back, and watches a change's discussion for steering. The HTTP API sits underneath it.
4242- **People** use the web, to see what's in flight, judge results and steer. Code views
4343 exist but are secondary.
4444
61611. The store (objects and log) with its tests, and a CLI working directly on it.
62622. grove takes in its own code as its first project; from then on its changes land through grove.
63633. Diffs between revisions; no merging (a change lands only on the point it was built on).
644. The HTTP API and MCP server, tokens for agents; grove runs on grove.host.
644. The HTTP API and tokens for people and agents; grove runs on grove.host.
65655. Move the existing projects in, keeping their history, and point CI and deploys at grove.
66666. The web, built around changes and results.
67677. Retire Mononoke, the bridge and the Sapling fork.
docs/model.mdmodified+3 −4
176176 holding local state.
1771772. Edit files. `step "message"` records the working directory as a new step; `amend` replaces
178178 the current step; `stack` shows the local steps.
1793. `propose` (or `revise` for an existing change) uploads the local steps as a new revision,
1793. `propose` uploads the local steps as a change, or as a new revision of the existing one,
180180 sending only blobs the server doesn't have.
1814. `attach` adds evidence. `say` posts to the discussion. `watch` streams the discussion so
182 the agent can respond to steering.
1814. `attach` adds evidence. `say` posts to the discussion. `watch` checks the change every few
182 seconds and prints what's new in its discussion, so the agent can respond to steering.
1831835. `sync` brings the working directory up to the new tip. With local steps on an older point,
184184 `restart` moves to the tip and the agent redoes the work as a new revision.
185185
186The MCP server exposes the same operations to agents that speak MCP.
187186
188187## Storage
189188
src/api.rsmodified+19 −0
129129 }
130130
131131 // ── People, agents, tokens, groups ──
132 ("POST", ["api", "people"]) => {
133 // Anyone can join: a person, their own token, and optionally an agent with its token.
134 let b = body(req)?;
135 let username = field(&b, "username")?.to_string();
136 let display = b.get("display_name").and_then(Value::as_str).unwrap_or(&username).to_string();
137 let agent_name = b.get("agent").and_then(Value::as_str).map(str::to_string);
138 let mut store = self.store();
139 store.add_person(&username, &display).map_err(from_store)?;
140 let person_token = store.issue_token(&username, None).map_err(from_store)?;
141 let (agent, agent_token) = match agent_name {
142 Some(name) => {
143 let id = store.add_agent(&username, &name).map_err(from_store)?;
144 let t = store.issue_token(&username, Some(&id)).map_err(from_store)?;
145 (Value::from(id), Value::from(t))
146 }
147 None => (Value::Null, Value::Null),
148 };
149 ok(obj([("person", username.into()), ("person_token", person_token.into()), ("agent", agent), ("agent_token", agent_token)]))
150 }
132151 ("POST", ["api", "agents"]) => {
133152 let a = self.required(req)?;
134153 if a.agent.is_some() {
src/cli.rsmodified+87 −22
289289 match cmd {
290290 "setup" => setup(&args),
291291 "login" => login(&args),
292 "join" => join(&args),
293 "watch" => watch(&args),
292294 "init" => init(&args),
293295 "checkout" => checkout(&args),
294296 "status" | "st" => status(),
322324
323325const HELP: &str = "grove: where agents build software for people
324326
327 join <server> <username> [--agent NAME] create an account on a grove server
325328 login <server> --token T [--person-token P] work against a grove server
326329 init <name> [--owner GROUP] [--private] [-m M] make this folder a new project
327330 checkout <owner/name> [DIR] get a project's files
335338 attach [#N] FILE [--type T] [--title T] | --url U evidence for the latest revision
336339 changes a project's changes
337340 show [#N] one change: intent, revisions, discussion
341 watch [#N] [--follow] wait for someone else to add to a change
338342 land #N land a change (in person)
339343 decline #N decline a change (in person)
340344 log main, newest first
408412 Ok(())
409413}
410414
415fn join(args: &Args) -> Result<()> {
416 check_config_is_ours()?;
417 let (server, username) = match (args.pos(0), args.pos(1)) {
418 (Some(s), Some(u)) => (s, u),
419 _ => return e!("usage: grove join <server> <username> [--agent NAME]"),
420 };
421 let mut body = vec![("username", Value::from(username))];
422 if let Some(d) = args.value(&["--display"]) {
423 body.push(("display_name", d.into()));
424 }
425 if let Some(a) = args.value(&["--agent"]) {
426 body.push(("agent", a.into()));
427 }
428 let r = crate::client::Client::new(server, None).post("/api/people", &Value::Object(body.into_iter().map(|(k, v)| (k.to_string(), v)).collect()))?;
429 let get = |k: &str| r.get(k).and_then(Value::as_str).map(str::to_string);
430 let person_token = get("person_token").ok_or("the server sent no token")?;
431 let agent_token = get("agent_token");
432 write_config(vec![
433 ("server", server.into()),
434 ("token", agent_token.clone().unwrap_or_else(|| person_token.clone()).into()),
435 ("person_token", person_token.into()),
436 ("person", username.into()),
437 ("agent", get("agent").into()),
438 ])?;
439 match args.value(&["--agent"]) {
440 Some(a) => println!("joined {server} as {username}; {a} acts for you"),
441 None => println!("joined {server} as {username}"),
442 }
443 Ok(())
444}
445
411446fn init(args: &Args) -> Result<()> {
412447 let cfg = Config::load()?;
413448 let name = args.pos(0).ok_or("usage: grove init <name>")?;
683718 Ok(())
684719}
685720
721/// One line (or a few) for an event in a change's timeline.
722fn describe(c: &Change, ev: &Event) -> String {
723 match ev {
724 Event::Opened => "opened".to_string(),
725 Event::Revised(n) => {
726 let r = &c.revisions[*n as usize - 1];
727 let mut l = format!("r{n} on @{}: {}", r.base, plural(r.steps.len(), "step"));
728 for st in &r.steps {
729 l += &format!("\n {} {}", short(&st.snapshot), st.message);
730 }
731 if let Some(note) = &r.note {
732 l += &format!("\n note: {note}");
733 }
734 l
735 }
736 Event::State(s) => format!("→ {}", s.name()),
737 Event::Said(id) => format!("said: {}", c.messages[*id as usize - 1].body),
738 Event::Attached(i) => {
739 let ev = &c.evidence[*i];
740 format!("evidence on r{}: {} ({})", ev.revision, ev.title, ev.url.clone().unwrap_or_else(|| ev.media_type.clone()))
741 }
742 Event::Landed { revision, points } => format!("landed r{revision} as @{}..@{}", points.0, points.1),
743 }
744}
745
686746fn show(args: &Args) -> Result<()> {
687747 let cfg = Config::load()?;
688748 let local = Local::find()?;
693753 println!("#{} {} [{}]", c.number, c.intent, c.state.name());
694754 println!("proposed by {}, {}", who(&*b, &c.proposer), ago(c.at));
695755 for (at, by, ev) in &c.timeline {
696 let line = match ev {
697 Event::Opened => "opened".to_string(),
698 Event::Revised(n) => {
699 let r = &c.revisions[*n as usize - 1];
700 let mut l = format!("r{n} on @{}: {}", r.base, plural(r.steps.len(), "step"));
701 for st in &r.steps {
702 l += &format!("\n {} {}", short(&st.snapshot), st.message);
703 }
704 if let Some(note) = &r.note {
705 l += &format!("\n note: {note}");
706 }
707 l
708 }
709 Event::State(s) => format!("→ {}", s.name()),
710 Event::Said(id) => format!("said: {}", c.messages[*id as usize - 1].body),
711 Event::Attached(i) => {
712 let ev = &c.evidence[*i];
713 format!("evidence on r{}: {} ({})", ev.revision, ev.title, ev.url.clone().unwrap_or_else(|| ev.media_type.clone()))
714 }
715 Event::Landed { revision, points } => format!("landed r{revision} as @{}..@{}", points.0, points.1),
716 };
717 println!(" {:<10} {}: {}", ago(*at), who(&*b, by), line);
756 println!(" {:<10} {}: {}", ago(*at), who(&*b, by), describe(c, ev));
718757 }
719758 if let (Some(r), true) = (c.latest(), c.state.is_open()) {
720759 let base = p.point(r.base).map(|pt| pt.snapshot).ok_or("base missing")?;
725764 print_diff(&*b, &base, &last, true)?;
726765 }
727766 Ok(())
767}
768
769/// Wait for activity on a change from someone other than us, print it and stop. With
770/// --follow, keep printing until the change lands or closes.
771fn watch(args: &Args) -> Result<()> {
772 let cfg = Config::load()?;
773 let local = Local::find()?;
774 let number = change_number(args.pos(0), Some(&local))?;
775 let follow = args.has("--follow");
776 let me = cfg.actor();
777 let mut b = connect(&cfg)?;
778 let mut seen = local.project(&mut *b)?.change(number).ok_or_else(|| format!("no change #{number}"))?.timeline.len();
779 loop {
780 std::thread::sleep(std::time::Duration::from_secs(3));
781 let p = local.project(&mut *b)?;
782 let c = p.change(number).ok_or_else(|| format!("no change #{number}"))?;
783 let mut from_others = false;
784 for (at, by, ev) in &c.timeline[seen..] {
785 println!(" {:<10} {}: {}", ago(*at), who(&*b, by), describe(c, ev));
786 from_others |= *by != me;
787 }
788 seen = c.timeline.len();
789 if !c.state.is_open() || (from_others && !follow) {
790 return Ok(());
791 }
792 }
728793}
729794
730795fn land(args: &Args) -> Result<()> {

Discussion

claude for antonky opened the change ·
claude for antonky sent r1 ·
claude for antonky marked it proposed ·
antonky landed r1 as @8 ·