Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions scripts/grove-agent.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Grove agent runner — spawned by TUI's Ctrl+P command palette.
# Usage: grove-agent.sh <role> [round]
#
# Reads GROVE.md for context, constructs a role-specific prompt,
# and runs acpx codex to execute the agent's task autonomously.

set -euo pipefail

ROLE="${1:?Usage: grove-agent.sh <role>}"
ROUND="${2:-1}"
GROVE_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GROVE_BIN="bun $GROVE_ROOT/dist/cli/main.js"

# Resolve nexus credentials from environment (set by grove up)
export NEXUS_API_KEY="${NEXUS_API_KEY:-}"
export GROVE_AGENT_ID="${ROLE}-$$"
export GROVE_AGENT_NAME="$(echo "$ROLE" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')"

echo "=== Grove Agent: $ROLE (round $ROUND) ==="
echo " grove root: $GROVE_ROOT"
echo " agent id: $GROVE_AGENT_ID"

# Read current frontier to know what's available
FRONTIER=$($GROVE_BIN frontier --json 2>/dev/null || echo '[]')
LOG=$($GROVE_BIN log --limit 5 2>/dev/null || echo 'No contributions yet')

# Build role-specific prompt
case "$ROLE" in
coder)
PROMPT="You are the CODER agent in a Grove review-loop. Working directory: $(pwd)

CONTEXT — Recent contributions:
$LOG

YOUR TASK (round $ROUND):
1. Read GROVE.md to understand the project
2. Create or improve a source file. If this is round 1, create src/utils/parser.ts with a simple CSV parser. If a later round, read previous reviews via 'grove log' and improve the code.
3. Submit your work by running:
NEXUS_API_KEY=$NEXUS_API_KEY GROVE_AGENT_ID=$GROVE_AGENT_ID GROVE_AGENT_NAME=$GROVE_AGENT_NAME $GROVE_BIN contribute --kind work --summary '<describe what you did>' --mode evaluation --artifacts <your-file>
4. Print the blake3 CID.

IMPORTANT: Only create/edit files and run the grove contribute command. Nothing else."
;;

reviewer)
# Find the latest work contribution to review
LATEST_WORK=$(echo "$LOG" | grep -o 'blake3:[a-f0-9]*' | head -1)
PROMPT="You are the REVIEWER agent in a Grove review-loop. Working directory: $(pwd)

CONTEXT — Recent contributions:
$LOG

YOUR TASK:
1. Find source files to review (look in src/ directory)
2. Read the code carefully
3. Write a 2-3 sentence code review identifying issues
4. Submit your review by running:
NEXUS_API_KEY=$NEXUS_API_KEY GROVE_AGENT_ID=$GROVE_AGENT_ID GROVE_AGENT_NAME=$GROVE_AGENT_NAME $GROVE_BIN review ${LATEST_WORK:-HEAD} --summary '<your review>' --score quality=<0.0-1.0>
5. Print the blake3 CID.

Score guide: 0.0-0.3 = poor, 0.4-0.6 = needs work, 0.7-0.8 = good, 0.9-1.0 = excellent.
IMPORTANT: Only read files and run the grove review command. Nothing else."
;;

*)
echo "Unknown role: $ROLE (expected: coder, reviewer)"
exit 1
;;
esac

echo " prompt length: ${#PROMPT} chars"
echo " launching acpx codex..."
echo ""

# Run the agent via acpx codex
npx acpx \
--approve-all \
--max-turns 8 \
--timeout 180 \
--format text \
codex exec "$PROMPT"

echo ""
echo "=== Agent $ROLE finished ==="
28 changes: 6 additions & 22 deletions src/cli/commands/contribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,30 +277,14 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c
throw new Error(`Invalid contribute options:\n ${validation.errors.join("\n ")}`);
}

// 2. Find .grove/
const grovePath = join(options.cwd, ".grove");
try {
await access(grovePath);
} catch {
throw new Error("No grove found. Run 'grove init' first to create a grove in this directory.");
}

// Dynamic imports for lazy loading
const { SqliteContributionStore, SqliteClaimStore, initSqliteDb } = await import(
"../../local/sqlite-store.js"
);
const { FsCas } = await import("../../local/fs-cas.js");
const { DefaultFrontierCalculator } = await import("../../core/frontier.js");
// 2. Find .grove/ and initialize stores (nexus or SQLite depending on grove.json)
const { initCliDeps } = await import("../context.js");
const { parseGroveContract } = await import("../../core/contract.js");
const { EnforcingContributionStore } = await import("../../core/enforcing-store.js");

const dbPath = join(grovePath, "grove.db");
const casPath = join(grovePath, "cas");
const db = initSqliteDb(dbPath);
const rawStore = new SqliteContributionStore(db);
const claimStore = new SqliteClaimStore(db);
const cas = new FsCas(casPath);
const frontier = new DefaultFrontierCalculator(rawStore);
const deps = initCliDeps(options.cwd);
const { claimStore, cas, frontier } = deps;
const rawStore = deps.store;

// 3. Load GROVE.md contract for enforcement, default mode, and metric directions
const grovemdPath = join(options.cwd, "GROVE.md");
Expand Down Expand Up @@ -479,7 +463,7 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c

return { cid: value.cid };
} finally {
db.close();
deps.close();
}
}

Expand Down
21 changes: 7 additions & 14 deletions src/cli/commands/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,12 @@
*/

import { parseArgs } from "node:util";
import { DefaultFrontierCalculator } from "../../core/frontier.js";
import type { Score } from "../../core/models.js";
import type { OperationDeps } from "../../core/operations/deps.js";
import { reviewOperation } from "../../core/operations/index.js";
import { FsCas } from "../../local/fs-cas.js";
import { createSqliteStores } from "../../local/sqlite-store.js";
import { resolveAgent } from "../agent.js";
import { initCliDeps } from "../context.js";
import { outputJson, outputJsonError } from "../format.js";
import { resolveGroveDir } from "../utils/grove-dir.js";

export interface ReviewOptions {
readonly targetCid: string;
Expand Down Expand Up @@ -87,18 +84,14 @@ export function parseReviewArgs(args: readonly string[]): ReviewOptions {

/** Execute `grove review` using the operations layer. */
export async function runReview(options: ReviewOptions, groveOverride?: string): Promise<void> {
const { dbPath, groveDir } = resolveGroveDir(groveOverride);
const stores = createSqliteStores(dbPath);
const cas = new FsCas(`${groveDir}/cas`);
const frontier = new DefaultFrontierCalculator(stores.contributionStore);

const deps = initCliDeps(process.cwd(), groveOverride);
try {
const agent = resolveAgent();
const opDeps: OperationDeps = {
contributionStore: stores.contributionStore,
claimStore: stores.claimStore,
cas,
frontier,
contributionStore: deps.store,
claimStore: deps.claimStore,
cas: deps.cas,
frontier: deps.frontier,
};

const result = await reviewOperation(
Expand Down Expand Up @@ -131,7 +124,7 @@ export async function runReview(options: ReviewOptions, groveOverride?: string):
console.log(` Summary: ${result.value.summary}`);
}
} finally {
stores.close();
deps.close();
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/cli/commands/up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ export async function handleUp(args: readonly string[], groveOverride?: string):
// Non-headless: always delegate to TUI — it shows the setup screen first,
// then handles service startup internally with progress feedback.
if (!opts.headless && !opts.noTui) {
// Puppet mode: run TUI in a pty subprocess with HTTP control API
if (process.env.GROVE_PUPPET_PORT) {
const { startPuppetAndServe } = await import("../../tui/puppet-server.js");
const tuiArgs = ["dist/cli/main.js", "up"];
if (effectiveGrove) tuiArgs.push("--grove", effectiveGrove);
await startPuppetAndServe(tuiArgs, Number(process.env.GROVE_PUPPET_PORT));
return;
}
const { handleTui } = await import("../../tui/main.js");
await handleTui([], effectiveGrove, { build: opts.build, nexusSource: opts.nexusSource });
return;
Expand Down
56 changes: 52 additions & 4 deletions src/cli/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@
* CLI commands.
*/

import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import type { ContentStore } from "../core/cas.js";
import { parseGroveConfig } from "../core/config.js";
import type { FrontierCalculator } from "../core/frontier.js";
import { DefaultFrontierCalculator } from "../core/frontier.js";
import type { OutcomeStore } from "../core/outcome.js";
import type { ClaimStore, ContributionStore } from "../core/store.js";
import type { WorkspaceManager } from "../core/workspace.js";
import type { FsCas } from "../local/fs-cas.js";
import { createLocalRuntime } from "../local/runtime.js";
import { NexusCas } from "../nexus/nexus-cas.js";
import { NexusClaimStore } from "../nexus/nexus-claim-store.js";
import { NexusContributionStore } from "../nexus/nexus-contribution-store.js";
import { NexusHttpClient } from "../nexus/nexus-http-client.js";
import { NexusOutcomeStore } from "../nexus/nexus-outcome-store.js";

const GROVE_DIR = ".grove";

Expand All @@ -23,7 +30,7 @@ export interface CliDeps {
readonly claimStore: ClaimStore;
readonly frontier: FrontierCalculator;
readonly workspace: WorkspaceManager;
readonly cas: FsCas;
readonly cas: ContentStore;
readonly groveRoot: string;
readonly outcomeStore?: OutcomeStore | undefined;
readonly close: () => void;
Expand Down Expand Up @@ -71,9 +78,50 @@ export function initCliDeps(cwd: string, groveOverride?: string): CliDeps {
);
}

// Use nexus-backed stores when grove.json declares mode "nexus"
const configPath = join(groveDir, "grove.json");
if (existsSync(configPath)) {
try {
const groveConfig = parseGroveConfig(readFileSync(configPath, "utf-8"));
if (groveConfig.mode === "nexus" && groveConfig.nexusUrl) {
const apiKey = process.env.NEXUS_API_KEY || undefined;
const client = new NexusHttpClient({ url: groveConfig.nexusUrl, apiKey });
const nexusConfig = { client, zoneId: "default" };

const store = new NexusContributionStore(nexusConfig);
const claimStore = new NexusClaimStore(nexusConfig);
const outcomeStore = new NexusOutcomeStore(nexusConfig);
const cas = new NexusCas(nexusConfig);
const frontier = new DefaultFrontierCalculator(store);

// Local runtime only for workspace manager (needs SQLite for tracking)
const runtime = createLocalRuntime({ groveDir, frontierCacheTtlMs: 0, workspace: true });

return {
store,
claimStore,
frontier,
workspace:
runtime.workspace ??
(() => {
throw new Error("Workspace manager failed");
})(),
cas,
groveRoot: resolve(groveDir, ".."),
outcomeStore,
close: () => {
runtime.close();
},
};
}
} catch {
// Config parse failed — fall through to local stores
}
}

const runtime = createLocalRuntime({
groveDir,
frontierCacheTtlMs: 0, // CLI commands are single-shot; no caching needed
frontierCacheTtlMs: 0,
workspace: true,
});

Expand Down
4 changes: 2 additions & 2 deletions src/cli/presets/review-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ export const reviewLoopPreset: PresetConfig = {
description: "Writes and iterates on code",
maxInstances: 1,
edges: [{ target: "reviewer", edgeType: "delegates" }],
command: "claude --dangerously-skip-permissions",
command: "scripts/grove-agent.sh coder",
},
{
name: "reviewer",
description: "Reviews code and provides feedback",
maxInstances: 1,
edges: [{ target: "coder", edgeType: "feedback" }],
command: "claude --dangerously-skip-permissions",
command: "scripts/grove-agent.sh reviewer",
},
],
spawning: { dynamic: true, maxDepth: 2 },
Expand Down
2 changes: 1 addition & 1 deletion src/nexus/nexus-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const ReadResultSchema = z.union([BytesResultSchema, LegacyReadResultSchema]);
const WriteResultSchema = z
.object({
bytes_written: z.number(),
etag: z.string(),
etag: z.string().optional().default(""),
version: z.number().optional(),
})
.passthrough();
Expand Down
61 changes: 52 additions & 9 deletions src/server/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@
* This is the only file excluded from test coverage — use createApp() for testing.
*/

import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { parseGroveConfig } from "../core/config.js";
import { DefaultFrontierCalculator } from "../core/frontier.js";
import type { GossipService } from "../core/gossip/types.js";
import { CachedFrontierCalculator } from "../gossip/cached-frontier.js";
import { HttpGossipTransport } from "../gossip/http-transport.js";
import { DefaultGossipService } from "../gossip/protocol.js";
import { createLocalRuntime } from "../local/runtime.js";
import { NexusBountyStore } from "../nexus/nexus-bounty-store.js";
import { NexusCas } from "../nexus/nexus-cas.js";
import { NexusClaimStore } from "../nexus/nexus-claim-store.js";
import { NexusContributionStore } from "../nexus/nexus-contribution-store.js";
import { NexusHttpClient } from "../nexus/nexus-http-client.js";
import { NexusOutcomeStore } from "../nexus/nexus-outcome-store.js";
import { parseGossipSeeds, parsePort } from "../shared/env.js";
import { createApp } from "./app.js";
import type { ServerDeps } from "./deps.js";
Expand All @@ -20,12 +30,45 @@ const GROVE_DIR = process.env.GROVE_DIR ?? join(process.cwd(), ".grove");
const PORT = parsePort(process.env.PORT, 4515);
const HOST = process.env.HOST; // optional — defaults to localhost via Bun

// Use nexus-backed stores when grove.json declares mode "nexus", else local SQLite
const configPath = join(GROVE_DIR, "grove.json");
const groveConfig = existsSync(configPath)
? (() => {
try {
return parseGroveConfig(readFileSync(configPath, "utf-8"));
} catch {
return null;
}
})()
: null;

const runtime = createLocalRuntime({
groveDir: GROVE_DIR,
workspace: false, // server doesn't need workspace manager
parseContract: true, // parse topology from GROVE.md
workspace: false,
parseContract: true,
});

// Build nexus stores if configured, otherwise fall through to runtime's SQLite stores
const useNexus = groveConfig?.mode === "nexus" && groveConfig.nexusUrl;
const nexusStores =
useNexus && groveConfig?.nexusUrl
? (() => {
const apiKey = process.env.NEXUS_API_KEY || undefined;
const client = new NexusHttpClient({ url: groveConfig.nexusUrl, apiKey });
const cfg = { client, zoneId: "default" };
const contributionStore = new NexusContributionStore(cfg);
const baseFrontier = new DefaultFrontierCalculator(contributionStore);
return {
contributionStore,
claimStore: new NexusClaimStore(cfg),
outcomeStore: new NexusOutcomeStore(cfg),
bountyStore: new NexusBountyStore(cfg),
cas: new NexusCas(cfg),
frontier: new CachedFrontierCalculator(baseFrontier, 30_000),
};
})()
: null;

// ---------------------------------------------------------------------------
// Optional gossip federation
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -54,13 +97,13 @@ if (seedPeers.length > 0) {
// ---------------------------------------------------------------------------

const deps: ServerDeps = {
contributionStore: runtime.contributionStore,
claimStore: runtime.claimStore,
outcomeStore: runtime.outcomeStore,
bountyStore: runtime.bountyStore,
goalSessionStore: runtime.goalSessionStore,
cas: runtime.cas,
frontier: runtime.frontier,
contributionStore: nexusStores?.contributionStore ?? runtime.contributionStore,
claimStore: nexusStores?.claimStore ?? runtime.claimStore,
outcomeStore: nexusStores?.outcomeStore ?? runtime.outcomeStore,
bountyStore: nexusStores?.bountyStore ?? runtime.bountyStore,
goalSessionStore: runtime.goalSessionStore, // always local (no nexus equivalent yet)
cas: nexusStores?.cas ?? runtime.cas,
frontier: nexusStores?.frontier ?? runtime.frontier,
gossip: gossipService,
topology: runtime.contract?.topology,
};
Expand Down
Loading