gm-orchestrator
v0.12.1
Published
Tokenless Claude Code orchestrator for GraphMemory — AI-first, TypeScript
Maintainers
Readme
gm-orchestrator
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.
How it works
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 completeEach Claude Code session gets a clean context window. No accumulation, no drift.
Prerequisites
- 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
Installation
npm install -g gm-orchestratorQuick Start
# 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.
Browser UI
The primary interface. Opens automatically at http://localhost:4242.
Dashboard
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.
Sprint Runner
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
Settings
- GraphMemory connection (URL, project ID, API key)
- Permissions — what Claude is allowed to do per session
- Notifications — Telegram, webhook, desktop
- Timeout and retry configuration
Permissions
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.
Notifications
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).
Configuration
Resolved in order — later sources override earlier:
- Defaults
.gm-orchestrator.jsonin current directory- Environment variables
- CLI flags
Config file
{
"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": ""
}
}
}Environment variables
| 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) |
All config options
| 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) |
Task dependencies
The orchestrator respects task blockers when choosing which task to run next. A task whose blockers are unresolved is skipped regardless of its priority.
Marking a blocker
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.
Link kinds
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. |
Cancelled blockers
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.
Cross-project blockers
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.
Programmatic check
import { areBlockersResolved } from 'gm-orchestrator';
// Returns true when every task linked with kind="blocks" is done or cancelled
areBlockersResolved(task); // booleanPost-task verification hooks
After 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.
Configuration
{
"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. |
On failure (onFailure: "block")
- 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.
Crash recovery (heartbeat)
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.
Configuration
{
"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.
Idempotency (double-spawn safety)
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 (cross-project orchestration)
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.
Configuration
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
API
| 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 }) |
UI
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.
Validation
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
CLI (advanced)
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 | |
Programmatic API
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);Core functions
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
Utilities
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)
Infrastructure
GraphMemoryClient— GraphMemory REST clientClaudeRunner— spawns claude --print sessionsTaskPoller— polls task status until completionconsoleLogger/silentLogger— logger implementations
Types
Task, Epic, TaskStatus, TaskPriority, EpicStatus, TaskRef,
OrchestratorConfig, TaskRunResult, SprintStats,
GraphMemoryPort, ClaudeRunnerPort, TaskPollerPort, Logger
License
MIT
