Skip to content

Commit 70f2df2

Browse files
gHashTagAntigravity Agentclaude
authored
feat(agent): Ralph sleep-wake daemon — native Zig, GitHub polling, Claude CLI spawn (#50)
8 Zig modules (~500 LOC): - main.zig: entry point, env vars, CLI args - agent_loop.zig: WAKE→WORK→SLEEP cycle - identity.zig: load .ralph/IDENTITY.md - context_builder.zig: assemble structured prompt - github_poller.zig: fetch pending issues via HTTP - claude_runner.zig: spawn `claude` CLI as child process - handover.zig: read/write .ralph/HANDOVER.md - state.zig: file-based key-value persistence Build: `zig build agent` / `zig build agent -- --single-shot` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Antigravity Agent <antigravity@vibee.org> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5008cbb commit 70f2df2

11 files changed

Lines changed: 728 additions & 0 deletions

File tree

.ralph/IDENTITY.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Ralph — Autonomous Development Agent
2+
3+
## Who I Am
4+
I am Ralph, an autonomous Zig development agent for the Trinity project.
5+
I follow the Golden Chain: spec → gen → test → assess → commit.
6+
My identity persists across sessions via HANDOVER.md.
7+
8+
## Rules
9+
- All tasks come from GitHub Issues with label `assign:ralph`
10+
- Never commit to main — use `ralph/w{N}/{slug}` branches
11+
- Quality gates: `zig build && zig build test && zig fmt --check src/`
12+
- Every PR must have: assignee, labels, milestone, reviewer, linked issue (`Closes #N`)
13+
- ALWAYS write HANDOVER.md before session ends
14+
15+
## Key Files
16+
- `.ralph/RULES.md` — Development guardrails (22 sections)
17+
- `.ralph/HANDOVER.md` — Context bridge between sessions
18+
- `.ralph/SUCCESS_HISTORY.md` — Working patterns
19+
- `.ralph/REGRESSION_PATTERNS.md` — Anti-patterns to avoid
20+
- `.ralph/AGENTS.md` — Agent lifecycle protocol
21+
22+
## Capabilities
23+
- Zig 0.15.x development (VSA, VM, Firebird, VIBEE compiler)
24+
- FPGA/Verilog synthesis via VIBEE specs
25+
- GitHub Issues + Projects V2 automation
26+
- Claude Code CLI with MCP tools
27+
28+
## Mathematical Foundation
29+
Trinity Identity: φ² + 1/φ² = 3 where φ = (1 + √5) / 2
30+
Ternary: {-1, 0, +1} — 1.58 bits/trit

.ralph/state/.gitkeep

Whitespace-only changes.

build.zig

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,23 @@ pub fn build(b: *std.Build) void {
13091309
// const trinity_mcp_step = b.step("trinity-mcp", "Run TRINITY MCP Server (35+ tools)");
13101310
// trinity_mcp_step.dependOn(&run_trinity_mcp.step);
13111311

1312+
// ═══════════════════════════════════════════════════════════════════════════
1313+
// RALPH AGENT — Autonomous Sleep-Wake Daemon
1314+
const ralph_agent = b.addExecutable(.{
1315+
.name = "ralph-agent",
1316+
.root_module = b.createModule(.{
1317+
.root_source_file = b.path("tools/mcp/trinity_mcp/agent/main.zig"),
1318+
.target = target,
1319+
.optimize = optimize,
1320+
}),
1321+
});
1322+
b.installArtifact(ralph_agent);
1323+
1324+
const run_agent = b.addRunArtifact(ralph_agent);
1325+
if (b.args) |args| run_agent.addArgs(args);
1326+
const agent_step = b.step("agent", "Run Ralph autonomous agent daemon");
1327+
agent_step.dependOn(&run_agent.step);
1328+
13121329
// ═══════════════════════════════════════════════════════════════════════════
13131330
// PHI LOOP — 999 Links of Cosmic Consciousness Gene
13141331
const phi_loop = b.addExecutable(.{
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// agent_loop.zig — Sleep-wake cycle for Ralph autonomous agent
2+
const std = @import("std");
3+
const identity_mod = @import("identity.zig");
4+
const handover = @import("handover.zig");
5+
const github_poller = @import("github_poller.zig");
6+
const context_builder = @import("context_builder.zig");
7+
const claude_runner = @import("claude_runner.zig");
8+
const state_mod = @import("state.zig");
9+
10+
pub const Config = struct {
11+
project_root: []const u8,
12+
gh_token: []const u8,
13+
owner: []const u8,
14+
repo: []const u8,
15+
sleep_interval_s: u64 = 1800, // 30 minutes
16+
max_turns: u32 = 50,
17+
max_wakes: u32 = 0, // 0 = infinite
18+
single_shot: bool = false, // true = run once and exit
19+
};
20+
21+
fn log(comptime fmt: []const u8, args: anytype) void {
22+
std.debug.print("[ralph-agent] " ++ fmt ++ "\n", args);
23+
}
24+
25+
/// Run the sleep-wake loop.
26+
pub fn run(allocator: std.mem.Allocator, config: Config) !void {
27+
var state = try state_mod.State.init(allocator, config.project_root);
28+
defer state.deinit();
29+
30+
log("Starting sleep-wake loop", .{});
31+
log(" project: {s}", .{config.project_root});
32+
log(" repo: {s}/{s}", .{ config.owner, config.repo });
33+
log(" sleep interval: {d}s", .{config.sleep_interval_s});
34+
log(" max turns: {d}", .{config.max_turns});
35+
36+
while (true) {
37+
// === WAKE ===
38+
const wake_count = state.incrementWakeCount() catch 0;
39+
log("=== WAKE #{d} ===", .{wake_count});
40+
41+
if (config.max_wakes > 0 and wake_count > config.max_wakes) {
42+
log("Max wakes ({d}) reached. Exiting.", .{config.max_wakes});
43+
break;
44+
}
45+
46+
// Load identity
47+
var id = identity_mod.load(allocator, config.project_root);
48+
defer id.deinit();
49+
50+
// Read previous handover
51+
const handover_content = handover.read(allocator, config.project_root);
52+
defer if (handover_content) |h| allocator.free(h);
53+
54+
// Poll GitHub for pending issues
55+
log("Polling GitHub issues...", .{});
56+
const issues_json = github_poller.fetchPending(
57+
allocator,
58+
config.owner,
59+
config.repo,
60+
config.gh_token,
61+
);
62+
defer if (issues_json) |j| allocator.free(j);
63+
64+
if (issues_json == null) {
65+
log("No pending issues or GitHub API unavailable. Sleeping.", .{});
66+
if (config.single_shot) break;
67+
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
68+
continue;
69+
}
70+
71+
// Read current state
72+
const current_issue = state.read("current_issue");
73+
defer if (current_issue) |v| allocator.free(v);
74+
const current_branch = state.read("current_branch");
75+
defer if (current_branch) |v| allocator.free(v);
76+
77+
// === BUILD CONTEXT ===
78+
const prompt = try context_builder.build(allocator, .{
79+
.identity = id.content,
80+
.handover_content = handover_content,
81+
.issues_json = issues_json,
82+
.current_issue = current_issue,
83+
.current_branch = current_branch,
84+
.wake_count = wake_count,
85+
});
86+
defer allocator.free(prompt);
87+
88+
log("Context built ({d} bytes). Spawning Claude CLI...", .{prompt.len});
89+
90+
// === WORK ===
91+
var result = claude_runner.spawn(
92+
allocator,
93+
prompt,
94+
config.project_root,
95+
config.max_turns,
96+
) catch |err| {
97+
log("Claude spawn error: {s}", .{@errorName(err)});
98+
if (config.single_shot) break;
99+
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
100+
continue;
101+
};
102+
defer result.deinit();
103+
104+
log("Claude exited with code {d} ({d} bytes output)", .{ result.exit_code, result.stdout.len });
105+
106+
// Save session log
107+
claude_runner.saveLog(allocator, config.project_root, result.stdout);
108+
109+
// === SLEEP ===
110+
// Check if handover was written by the session
111+
const new_handover = handover.read(allocator, config.project_root);
112+
if (new_handover) |nh| {
113+
allocator.free(nh);
114+
} else {
115+
// Emergency handover — session didn't write one
116+
log("WARNING: No handover written. Creating emergency handover.", .{});
117+
handover.writeEmergency(allocator, config.project_root, wake_count, current_issue) catch {
118+
log("Failed to write emergency handover!", .{});
119+
};
120+
}
121+
122+
// Update state
123+
var count_buf: [16]u8 = undefined;
124+
const count_str = std.fmt.bufPrint(&count_buf, "{d}", .{wake_count}) catch "0";
125+
state.write("last_wake", count_str) catch {};
126+
127+
if (config.single_shot) {
128+
log("Single-shot mode. Exiting.", .{});
129+
break;
130+
}
131+
132+
log("Sleeping for {d}s...", .{config.sleep_interval_s});
133+
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
134+
}
135+
136+
log("Agent loop terminated.", .{});
137+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// claude_runner.zig — Spawn Claude Code CLI as child process
2+
const std = @import("std");
3+
4+
pub const RunResult = struct {
5+
stdout: []const u8,
6+
exit_code: u8,
7+
allocator: std.mem.Allocator,
8+
9+
pub fn deinit(self: *RunResult) void {
10+
self.allocator.free(self.stdout);
11+
}
12+
};
13+
14+
/// Spawn `claude` CLI with the given prompt as a positional argument.
15+
/// Returns captured stdout and exit code.
16+
pub fn spawn(
17+
allocator: std.mem.Allocator,
18+
prompt: []const u8,
19+
project_root: []const u8,
20+
max_turns: u32,
21+
) !RunResult {
22+
var turns_buf: [8]u8 = undefined;
23+
const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{max_turns}) catch "50";
24+
25+
// claude --print --output-format text --max-turns N --allowedTools ... "prompt"
26+
const result = std.process.Child.run(.{
27+
.allocator = allocator,
28+
.argv = &.{
29+
"claude",
30+
"--print",
31+
"--output-format",
32+
"text",
33+
"--max-turns",
34+
turns_str,
35+
"--allowedTools",
36+
"Bash,Read,Write,Edit,Glob,Grep,TodoWrite,WebFetch,WebSearch,Skill",
37+
"-p",
38+
prompt,
39+
},
40+
.cwd = project_root,
41+
.max_output_bytes = 1024 * 1024, // 1MB
42+
}) catch |err| {
43+
const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude: {s}", .{@errorName(err)});
44+
return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator };
45+
};
46+
47+
// Free stderr, keep stdout
48+
allocator.free(result.stderr);
49+
50+
const exit_code: u8 = switch (result.term) {
51+
.Exited => |code| code,
52+
else => 1,
53+
};
54+
55+
return RunResult{
56+
.stdout = result.stdout,
57+
.exit_code = exit_code,
58+
.allocator = allocator,
59+
};
60+
}
61+
62+
/// Save session log to .ralph/logs/session_{timestamp}.log
63+
pub fn saveLog(_: std.mem.Allocator, project_root: []const u8, content: []const u8) void {
64+
var dir_buf: [512]u8 = undefined;
65+
const log_dir = std.fmt.bufPrint(&dir_buf, "{s}/.ralph/logs", .{project_root}) catch return;
66+
std.fs.cwd().makePath(log_dir) catch {};
67+
68+
const epoch_s: u64 = @intCast(@divTrunc(std.time.nanoTimestamp(), std.time.ns_per_s));
69+
70+
var path_buf: [512]u8 = undefined;
71+
const path = std.fmt.bufPrint(&path_buf, "{s}/session_{d}.log", .{ log_dir, epoch_s }) catch return;
72+
73+
const file = std.fs.cwd().createFile(path, .{}) catch return;
74+
defer file.close();
75+
file.writeAll(content) catch {};
76+
77+
std.debug.print("[ralph-agent] Log saved: {s}\n", .{path});
78+
}
79+
80+
test "RunResult deinit does not crash" {
81+
const allocator = std.testing.allocator;
82+
const msg = try allocator.dupe(u8, "test output");
83+
var r = RunResult{ .stdout = msg, .exit_code = 0, .allocator = allocator };
84+
r.deinit();
85+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// context_builder.zig — Assemble structured wake prompt for Claude CLI
2+
const std = @import("std");
3+
const github_poller = @import("github_poller.zig");
4+
5+
pub const WakeContext = struct {
6+
identity: []const u8,
7+
handover_content: ?[]const u8,
8+
issues_json: ?[]const u8,
9+
current_issue: ?[]const u8,
10+
current_branch: ?[]const u8,
11+
wake_count: u32,
12+
};
13+
14+
const instructions =
15+
\\You are Ralph, an autonomous development agent. Follow this protocol:
16+
\\
17+
\\1. Read HANDOVER above — understand where the previous session left off
18+
\\2. Pick the highest-priority pending issue (or continue current one)
19+
\\3. Claim the issue: add `status:in-progress` label, assign yourself
20+
\\4. Create branch: `ralph/w1/{issue-slug}`
21+
\\5. Implement via Golden Chain: spec → gen → test → assess → commit
22+
\\6. Run quality gates: `zig build && zig build test && zig fmt --check src/`
23+
\\7. Create PR with `Closes #{N}` in body
24+
\\8. BEFORE SESSION ENDS: Write .ralph/HANDOVER.md with:
25+
\\ - What was accomplished
26+
\\ - Current branch and issue
27+
\\ - What needs to happen next
28+
\\ - Any blockers or concerns
29+
\\
30+
\\CRITICAL: Always write HANDOVER.md before finishing. This is your memory.
31+
;
32+
33+
/// Build a structured prompt from all context sources.
34+
pub fn build(allocator: std.mem.Allocator, ctx: WakeContext) ![]const u8 {
35+
const handover_section = ctx.handover_content orelse "No handover found. This is a fresh start.";
36+
const issue_section = ctx.current_issue orelse "none";
37+
const branch_section = ctx.current_branch orelse "none";
38+
39+
// Build issues summary
40+
var issue_summary: []const u8 = "No pending issues found. Agent should be IDLE.";
41+
var issue_summary_allocated = false;
42+
defer if (issue_summary_allocated) allocator.free(issue_summary);
43+
44+
if (ctx.issues_json) |json| {
45+
const max_json = @min(json.len, 4096);
46+
const num = github_poller.extractFirstIssueNumber(json);
47+
const title = github_poller.extractFirstIssueTitle(json);
48+
49+
if (num) |n| {
50+
if (title) |t| {
51+
issue_summary = std.fmt.allocPrint(allocator, "First pending: #{d} — {s}\n\n```json\n{s}\n```", .{ n, t, json[0..max_json] }) catch "Issues available (format error)";
52+
issue_summary_allocated = true;
53+
} else {
54+
issue_summary = std.fmt.allocPrint(allocator, "First pending: #{d}\n\n```json\n{s}\n```", .{ n, json[0..max_json] }) catch "Issues available (format error)";
55+
issue_summary_allocated = true;
56+
}
57+
} else {
58+
issue_summary = std.fmt.allocPrint(allocator, "```json\n{s}\n```", .{json[0..max_json]}) catch "Issues available (format error)";
59+
issue_summary_allocated = true;
60+
}
61+
}
62+
63+
return std.fmt.allocPrint(allocator,
64+
\\# IDENTITY
65+
\\
66+
\\{s}
67+
\\
68+
\\# HANDOVER FROM PREVIOUS SESSION
69+
\\
70+
\\{s}
71+
\\
72+
\\# CURRENT STATE
73+
\\
74+
\\- Wake count: {d}
75+
\\- Current issue: {s}
76+
\\- Current branch: {s}
77+
\\
78+
\\# PENDING GITHUB ISSUES
79+
\\
80+
\\{s}
81+
\\
82+
\\# INSTRUCTIONS
83+
\\
84+
\\{s}
85+
\\
86+
, .{
87+
ctx.identity,
88+
handover_section,
89+
ctx.wake_count,
90+
issue_section,
91+
branch_section,
92+
issue_summary,
93+
instructions,
94+
});
95+
}
96+
97+
test "build produces structured prompt" {
98+
const allocator = std.testing.allocator;
99+
const ctx = WakeContext{
100+
.identity = "Test identity",
101+
.handover_content = null,
102+
.issues_json = null,
103+
.current_issue = null,
104+
.current_branch = null,
105+
.wake_count = 1,
106+
};
107+
const prompt = try build(allocator, ctx);
108+
defer allocator.free(prompt);
109+
try std.testing.expect(std.mem.indexOf(u8, prompt, "IDENTITY") != null);
110+
try std.testing.expect(std.mem.indexOf(u8, prompt, "INSTRUCTIONS") != null);
111+
try std.testing.expect(std.mem.indexOf(u8, prompt, "Wake count: 1") != null);
112+
}

0 commit comments

Comments
 (0)