Autonomous AI sprint runner for GraphMemory.
Spawns Claude Code sessions to work through your tasks automatically — one task at a time, fresh context each session, no token bleed. You define the work in GraphMemory, hit Run, go do something else.
npm install -g gm-orchestrator
gm-orchestrator→ Opens http://localhost:4242 in your browser. That's it.
gm-orchestrator
↓
Reads tasks from GraphMemory (priority order, blockers respected)
↓
For each task:
spawn → claude --print "<task prompt>"
wait → Claude calls tasks_move("done") when finished
next → kill session, start fresh, repeat
↓
Notify you when sprint/epic is complete
Each Claude Code session gets a clean context window. No accumulation, no drift.
- Node.js >= 18
- GraphMemory running locally (
graphmemory serve) - Claude Code installed (
npm install -g @anthropic-ai/claude-code) - Claude Code connected to your GraphMemory instance via MCP
npm install -g gm-orchestrator# 1. Start GraphMemory in your project
cd /path/to/your-project
graphmemory serve
# 2. Start gm-orchestrator
gm-orchestrator
# → opens http://localhost:4242On first run, the wizard walks you through selecting a project and configuring permissions.
The primary interface. Opens automatically at http://localhost:4242.
Overview of your project: task queue sorted by priority, epics, done count for today. One click to start a sprint or select an epic. Choose the Claude model (Sonnet, Opus, Haiku) from the dropdown before starting.
Live view while work is in progress:
- Task queue with status (queued / running / done / cancelled)
- Real-time Claude log stream — see exactly what Claude is doing
- Progress bar and elapsed time per task
- Stop button
- GraphMemory connection (URL, project ID, API key)
- Permissions — what Claude is allowed to do per session
- Notifications — Telegram, webhook, desktop
- Timeout and retry configuration
Control what Claude Code can do in each session:
{
"permissions": {
"writeFiles": true,
"runCommands": ["npm test", "npm run build", "git add", "git commit"],
"blockedCommands": ["git push", "npm publish"]
}
}Translates directly to --allowedTools flags passed to claude --print.
Claude cannot run blocked commands even if it tries.
Get notified when work is done — useful for long sprints.
Telegram:
{
"notifications": {
"telegram": {
"botToken": "...",
"chatId": "..."
},
"on": ["sprint_complete", "task_failed"]
}
}Webhook (generic POST — works with Slack, Discord, custom endpoints):
{
"notifications": {
"webhook": {
"url": "https://your-endpoint.com/notify",
"headers": { "Authorization": "Bearer ..." }
}
}
}Desktop — native OS notification (macOS, Linux, Windows).
Resolved in order — later sources override earlier:
- Defaults
.gm-orchestrator.jsonin current directory- Environment variables
- CLI flags
{
"baseUrl": "http://localhost:3000",
"projectId": "my-app",
"apiKey": "",
"timeoutMs": 900000,
"pauseMs": 2000,
"maxRetries": 1,
"claudeArgs": [],
"permissions": {
"writeFiles": true,
"runCommands": ["npm test", "git commit"],
"blockedCommands": ["git push", "npm publish"]
},
"notifications": {
"telegram": {
"botToken": "",
"chatId": ""
}
}
}| Variable | Maps to |
|---|---|
GM_BASE_URL |
baseUrl |
GM_PROJECT_ID |
projectId |
GM_API_KEY |
apiKey |
GM_TIMEOUT_MS |
timeoutMs |
GM_PORT |
server port (default: 4242) |
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl |
string |
http://localhost:3000 |
GraphMemory server URL |
projectId |
string |
(required) | GraphMemory project ID |
apiKey |
string |
API key (if GM auth is enabled) | |
timeoutMs |
number |
900000 (15 min) |
Per-task timeout in ms |
pauseMs |
number |
2000 |
Pause between tasks |
maxRetries |
number |
1 |
Retries on timeout/error |
claudeArgs |
string[] |
[] |
Extra args for claude --print |
model |
string |
Claude model override (e.g. claude-sonnet-4-6) |
|
dryRun |
boolean |
false |
Preview prompts, don't run |
postTaskHooks |
PostTaskHook[] |
[] |
Verification commands run after each task (see Post-task verification hooks) |
heartbeat |
HeartbeatConfig |
Heartbeat / zombie recovery settings (see Crash recovery) |
The orchestrator respects task blockers when choosing which task to run next. A task whose blockers are unresolved is skipped regardless of its priority.
Use tasks_link with kind="blocks":
tasks_link(fromId="U13", toId="U7", kind="blocks")
This means U13 blocks U7 — the orchestrator will not start U7 until U13 reaches done (or cancelled).
Concrete example: task U13 creates a lock helper used by U7 and U9. Link U13→U7 and U13→U9 with kind="blocks" so the orchestrator picks U13 first, even if U7 and U9 have higher priority.
GraphMemory supports four link kinds between tasks:
| Kind | Effect on orchestrator |
|---|---|
blocks |
Enforced. Target task is skipped until the source is terminal (done / cancelled). Checked by areBlockersResolved(task). |
prefers_after |
Soft. Target task is deprioritised while the source is still active, but not blocked. If the source is cancelled or stuck, the target can still run. Useful for "ideally A before B" without a hard gate. |
subtask_of |
Structural only — groups tasks under a parent. Does not affect scheduling order. |
related_to |
Informational only — no effect on scheduling. |
A blocker in cancelled state is treated as resolved, same as done. Semantically, "this work is not going to happen" is equivalent to "this work is already accomplished" from the dependent task's perspective. Cancelling a blocker unblocks everything downstream — no ghost-blocked tasks stalling forever.
For multi-project setups, a blocker can live in a different GraphMemory project. Pass targetProjectId when linking:
tasks_link(fromId="U-API-1", toId="U6", kind="blocks", targetProjectId="MixPlacesEcommerce")
This means U-API-1 in the current project blocks U6 in MixPlacesEcommerce. The orchestrator resolves blocker status across all configured projects, so you can run multiple epics in parallel and let the scheduler interleave tasks based on real-time readiness — no need to sequence epics by hand.
import { areBlockersResolved } from 'gm-orchestrator';
// Returns true when every task linked with kind="blocks" is done or cancelled
areBlockersResolved(task); // booleanAfter a task is marked done, the orchestrator can run user-configurable verification commands before accepting the done state. This provides a hard gate on quality — "task done = runtime verified" is enforced mechanically, not by convention.
Hooks run in the orchestrator process, not inside the spawned Claude session. A failing session cannot skip its own verification.
{
"postTaskHooks": [
{
"name": "make-verify",
"command": "make verify",
"cwd": "/path/to/project",
"timeoutMs": 600000,
"onFailure": "block"
},
{
"name": "lint",
"command": "npm run lint",
"onFailure": "warn"
}
]
}| Field | Type | Description |
|---|---|---|
name |
string |
Human-readable label for logs. |
command |
string |
Shell command to execute. |
cwd |
string |
Working directory (default: process.cwd()). |
timeoutMs |
number |
Timeout in ms (default: 600000 = 10 min). |
onFailure |
'block' | 'warn' |
block halts the sprint, warn logs and continues. |
- Task is moved back from
donetoin_progress - An
auto-verify-failedtag is added - Last 50 lines of stdout/stderr are attached to
task.metadata.verifyFailures - The sprint halts — orchestrator does not pick the next task until the user intervenes
Hooks run sequentially in the order declared. Multiple hooks per project (e.g. lint + build + test as separate entries) are supported.
If the orchestrator process dies mid-task (OS reboot, OOM, pkill), the task is left stuck in in_progress forever. Heartbeats give the orchestrator a way to tell live work from zombie state.
While a task is running, the orchestrator writes metadata.runId and metadata.heartbeatAt on a 30s interval. On startup, it scans all in_progress tasks: any task with a stale heartbeat (older than 2× the interval) or no heartbeat at all is treated as a zombie and recovered according to the configured policy.
{
"heartbeat": {
"intervalMs": 30000,
"staleThresholdMs": 60000,
"zombiePolicy": "reset-to-todo"
}
}| Field | Default | Description |
|---|---|---|
intervalMs |
30000 |
How often to write heartbeat metadata. |
staleThresholdMs |
2 × intervalMs |
Age threshold for zombie detection. |
zombiePolicy |
reset-to-todo |
reset-to-todo, move-to-review, or cancel. |
reset-to-todo is the safe default: the zombie task goes back in the queue and will be re-picked with a fresh Claude session. Given per-task isolation, redoing the work is usually fine.
Each run gets a unique runId UUID, exposed to the spawned Claude session via the ORCHESTRATOR_RUN_ID environment variable and written to task.metadata.runId. Before making destructive changes, a well-behaved subagent can call tasks_get and compare metadata.runId to its own ORCHESTRATOR_RUN_ID — if they differ, another run has superseded this one and it should exit cleanly. This protects against race conditions between two orchestrator instances or restarts that happen during the narrow window between "pick task" and "move to in_progress".
Pipelines orchestrate epics across multiple projects with dependency ordering. A pipeline is a DAG of stages — each stage runs an epic in a specific project, and declares after dependencies on other stages. Independent stages run in parallel.
Add a pipelines array to .gm-orchestrator.json:
{
"projects": [
{ "baseUrl": "http://localhost:3000", "projectId": "backend-api" },
{ "baseUrl": "http://localhost:3000", "projectId": "frontend-app" },
{ "baseUrl": "http://localhost:3000", "projectId": "e2e-tests" }
],
"pipelines": [
{
"id": "full-stack-release",
"name": "Full-stack release",
"stages": [
{ "id": "backend", "projectId": "backend-api", "epicId": "api-v2" },
{ "id": "frontend", "projectId": "frontend-app", "epicId": "ui-update", "after": ["backend"] },
{ "id": "e2e", "projectId": "e2e-tests", "epicId": "smoke-tests", "after": ["backend", "frontend"] }
]
}
]
}In this example:
- backend runs first (no dependencies)
- frontend waits for backend to complete
- e2e waits for both backend and frontend
| Endpoint | Method | Description |
|---|---|---|
/api/pipelines |
GET | List configured pipelines |
/api/pipelines/run |
POST | Start a pipeline run ({ pipelineId }) |
/api/pipelines/run/status |
GET | Pipeline run state (stages + statuses) |
/api/pipelines/run/stop |
POST | Stop a pipeline run ({ pipelineRunId }) |
Pipelines appear on the Dashboard above the project cards. Each pipeline card shows:
- Stage count and dependency visualization
- Run button to start the pipeline
- Live stage status (queued / running / done / failed) when a run is active
On the Runs page, pipeline runs appear as grouped entries with a progress bar showing stages done / total.
Pipeline configs are validated on load:
- Stage IDs must be unique within a pipeline
afterreferences must point to existing stage IDs- No cycles allowed (DAG validation via topological sort)
- Each stage must have
projectIdandepicId
The browser UI is the primary interface. CLI commands are available for scripting and CI.
# Start UI (default)
gm-orchestrator
# Run sprint headless (no browser)
gm-orchestrator sprint --project my-app
# Run specific epic headless
gm-orchestrator epic <epicId> --project my-app
# Check task counts
gm-orchestrator status --project my-app
# Preview prompts without spawning Claude
gm-orchestrator sprint --project my-app --dry-run| Flag | Description | Default |
|---|---|---|
--project, -p |
GraphMemory project ID | env / config |
--tag, -t |
Filter tasks by tag | |
--timeout <min> |
Per-task timeout in minutes | 15 |
--retries <n> |
Retries on timeout/error | 1 |
--dry-run |
Preview prompts, don't spawn Claude | |
--config |
Print resolved config and exit |
import {
runSprint,
runEpic,
GraphMemoryClient,
ClaudeRunner,
TaskPoller,
consoleLogger,
loadConfig,
} from 'gm-orchestrator';
const config = loadConfig({ projectId: 'my-app' });
const gm = new GraphMemoryClient({ baseUrl: config.baseUrl, projectId: config.projectId });
const ports = {
gm,
runner: new ClaudeRunner(),
poller: new TaskPoller(gm),
logger: consoleLogger,
};
await runSprint(ports, config);
await runEpic('epic-id', ports, config);runSprint(ports, config)— run all todo/in_progress tasks by priorityrunEpic(epicId, ports, config)— run all tasks in an epicbuildPrompt(task)— generate autonomous-execution prompt for a task
sortByPriority(tasks)— sort by priority orderisTerminal(status)— check if status is done/cancelledareBlockersResolved(task)— check all blockers are doneloadConfig(overrides?)— load and merge configvalidateConfig(config)— validate (throws on missing projectId)
GraphMemoryClient— GraphMemory REST clientClaudeRunner— spawns claude --print sessionsTaskPoller— polls task status until completionconsoleLogger/silentLogger— logger implementations
Task, Epic, TaskStatus, TaskPriority, EpicStatus, TaskRef,
OrchestratorConfig, TaskRunResult, SprintStats,
GraphMemoryPort, ClaudeRunnerPort, TaskPollerPort, Logger
MIT