feat: Grok Build host surface + host-agnostic team bus (ADR-402) (#3512)

* feat: Grok Build host surface + host-agnostic team bus (ADR-402)

Adds `ruflo init --grok` and nine team_* MCP tools. Together they let a
lead agent on Grok Build (or Codex, or Claude) run a named agent team
without Claude's SendMessage. The team state lives in the project under
.claude-flow/teams/.

Team bus (team_create, team_spawn, team_send, team_broadcast,
team_inbox, team_plan, team_status, team_on_stop, team_shutdown)
- One implementation: templates/grok/scripts/grok-team-bus.mjs (ops,
  CLI) plus grok-team-store.mjs (storage). The MCP tools import these
  files, the SubagentStop hook imports them, and init --grok copies
  them into a project as a CLI with no dependencies. The repo root has
  no second copy.
- Mail is scoped per team: teams/<team>/mailbox/<agent>/. team_inbox
  requires the team, and team_status counts only that team's mail.
- team.json updates take a cross-process lock (team.lock, an O_EXCL
  file holding an owner token) and are written through a temp file and
  a rename. A stale lock is broken by renaming it aside and checking
  the token, never by a blind delete, and release removes only the
  holder's own token. 30 concurrent spawns
  from separate processes register all 30.
- team_shutdown closes the team. After it, spawn, send, plan and
  on-stop refuse. team_spawn enforces maxAgents.
- A broadcast with no members is refused. priority is an integer from
  0 to 999, stored as a zero-padded filename prefix. Symlinked mailbox
  directories are refused. Concurrent drains claim each message by
  rename, so no message is delivered twice and no drain throws ENOENT.
- The spawn description is <role>:<agent>@<team>. The SubagentStop
  hook parses it to advance that team's plan. When the hook cannot
  record a team subagent, or the team is ambiguous, it writes the
  reason to stderr and exits 1.

init --grok
- Writes .grok/ (config, rules, four agent profiles, a skill, the
  SubagentStop hook), scripts/ and docs/grok/README.md in the project,
  and lists every path it wrote. --dry-run writes nothing.
- Pins the project MCP server to the running release (ruflo@<version>,
  which ships team_*) instead of @latest. From a source checkout it
  points at that checkout's bin/cli.js. Inserted values are escaped as
  TOML strings, so Windows paths are valid.
- Writes nothing outside the project unless --grok-statusline is
  passed. With the flag, it installs ~/.grok/ruflo/host-statusline.mjs
  and merges [ui.status_line] into ~/.grok/config.toml using
  @iarna/toml. The merge leaves an existing status row alone in any
  TOML form. It writes only when the re-parsed result keeps every
  other value, and otherwise prints the snippet to paste. The command
  is `node "<absolute path>"`, which needs no shell and runs no
  script from the repo.
- The status row expects only the agent-teams-grok skill.

Also classifies team_* in the capability brain (swarm-coordination)
and adds ADR-402.

Tests: __tests__/team-tools.test.ts, __tests__/grok-init.test.ts.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(grok-host): address round-2 review of ADR-402 team bus (#3512)

N1 (blocking): root package.json "files" listed cli/dist but not
cli/templates, so npm pack (and npx claude-flow) shipped a build that
team_create / init --grok couldn't find grok-team-bus.mjs /
grok-team-store.mjs from. Added v3/@claude-flow/cli/templates/** and
verified with a real npm pack + tar extract (files present with the fix,
absent without it).

N2: breakStaleLock renamed a stale lock without re-checking it was still
the lock it first observed. A waiter that read a stale token could have
that exact lock broken by someone else and re-acquired fresh in between,
and the rename would steal that live lock out from under its holder,
opening a window for a fourth party to grab the vacated slot — two
holders at once. Now re-verifies token + mtime immediately before
renaming and backs off (no rename at all) if either changed. Added a
direct regression test (monkeypatched fs to force the exact interleaving)
plus a concurrent-CLI-process test with a pre-seeded stale lock.

N3: LOCK_WAIT_MS (10s) was shorter than LOCK_STALE_MS (30s), so any
acquire attempted in the ~20s gap between "waiter gives up" and "lock
considered stale" failed even with nothing actually stuck — and the
SubagentStop hook's own 5s command timeout couldn't survive either value.
Lowered stale/wait to 4s/4.5s (wait >= stale, both comfortably under the
hook's 5s budget with real critical sections being sub-millisecond JSON
r-m-w). Also replaced the synchronous Atomics.wait lock-wait loop with an
async setTimeout-based poll (sleepMsAsync), so an MCP server keeps serving
other requests while a caller waits for the lock; threaded async/await
through withTeamLock/createTeam/spawnMember/runCli/main and the
SubagentStop hook and team-tools.ts's dispatcher. sleepMs (sync) stays
only for writeJsonAtomic's bounded Windows rename retry.

Minor 1: --grok-statusline replaced a symlinked ~/.grok/config.toml with
a plain file, destroying the symlink. writeFileAtomic now resolves an
existing symlink and writes through it instead.

Minor 2: a TOML integer above 2^53 in the user's config parses to a
BigInt (@iarna/toml) and crashed JSON.stringify inside the status-line
merge, which ran as the LAST step of init — leaving project files already
written. Now validated up front, before any file is touched, naming the
offending key; init fails clean with nothing written instead of half-done.

Minor 3: host-statusline.mjs matched `trusted = true` / `[mcp_servers.x]`
via substring/regex, so a commented-out trusted=true read as trusted and
an `enabled = false` server still counted as loaded. Can't add a runtime
dependency on @iarna/toml here (this script ships as a zero-dependency
copy outside any node_modules tree — project scripts/ and
~/.grok/ruflo/host-statusline.mjs). Added a small comment-aware
table/key parser instead and used it for grok, and for codex's
mcp_servers check too (same bug class).

Minor 4: team_create --force reopened a team but left the old
incarnation's mailboxes on disk. Now clears mailbox/ when recreating.

Minor 5: priority accepted "1e2" and "0x10" (Number() coerces both).
Now requires a plain base-10 integer literal string.

Minor (stray changes): reverted an incidental .helpers-version bump and
a router.js mode change (644->755) picked up from a concurrent session's
helper auto-refresh — not part of this PR.

Verified: cli build + tsc --noEmit clean; grok-init.test.ts and
team-tools.test.ts (41 tests, including 8 new ones) and
guidance-brain.test.ts pass; full `npm test` for @claude-flow/cli is
green except 20 pre-existing failures across 16 files, all from
@claude-flow/memory / @claude-flow/mcp / @claude-flow/codex not being
built in this environment (none touch grok/team/statusline code).

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(grok-host): address round-3 review nits on the team lock and status line (#3512)

A lock whose holder pid is still alive is no longer broken after 4s, so a stalled writer does not lose an update. An existing [ui.status_line] no longer fails init when another value is a BigInt. Quoted trusted-folder keys may contain ].

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(grok-host): keep the TOML parser out of the MCP tool import path (#3512)

grokTemplatesRoot moves to init/grok-templates.ts, which has no
dependencies. The team_* tools import it from there, so loading the MCP
tool table no longer pulls in grok-generator and @iarna/toml. On current
main, analyze-json-output.test.ts runs the analyze command from a copied
source tree with only cli-core and security beside it, and failed 20 of
20 with "Cannot find package '@iarna/toml'" before this change.
grok-generator re-exports grokTemplatesRoot, so its callers are unchanged.

Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: RuFlo <ruv@ruv.net>
This commit is contained in:
Mathew BeaneandRuFlo authored and GitHub committed 2026-10-20 05:42:06 -04:00
1 parent f67d3b201f
commit 953d8d0018
30 files changed
+4223 -2

No files matched your search

+2
View File
@@ -118,6 +118,8 @@ User → Ruflo (CLI/MCP) → Router → Swarm → Agents → Memory → LLM prov
A harness is the workbench around the model: it organizes tasks, carries context between steps and helps check results. **This establishes Ruflo's lineage, not a universal first across every Claude Code tool.** [See the illustrated explainer, exact commits and earlier ecosystem context](docs/harness-origins.md).
**Grok Build:** `npx ruflo init --grok` writes the project host surface (`.grok/`, the team bus, and the operator guide) and nothing outside the project; add `--dry-run` to preview. Setup, the `spawn_subagent` contract, the optional status row, and what to re-check when Grok updates: [the Grok operator guide](v3/@claude-flow/cli/templates/grok/docs/README.md).
<details>
<summary><strong>📖 Background — where the name comes from</strong></summary>
+1
View File
@@ -30,6 +30,7 @@
"!v3/@claude-flow/cli/dist/**/*.map",
"v3/@claude-flow/cli/package.json",
"v3/@claude-flow/cli/catalog-manifest.json",
"v3/@claude-flow/cli/templates/**",
"v3/@claude-flow/shared/dist/**/*.js",
"v3/@claude-flow/shared/dist/**/*.d.ts",
"!v3/@claude-flow/shared/dist/**/*.map",
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
/** Grok is the default host. The check itself is scripts/probe-host-live.mjs. */
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const extra = process.argv.slice(2);
if (!extra.includes('--host')) extra.unshift('--host', 'grok');
const r = spawnSync(process.execPath, [join(here, 'probe-host-live.mjs'), ...extra], {
stdio: 'inherit',
});
process.exit(r.status == null ? 1 : r.status);
+474
View File
@@ -0,0 +1,474 @@
#!/usr/bin/env node
/**
* probe-host-live.mjs — one Ruflo host check, several CLIs.
*
* The contract is the same on every host:
* discover did this process load rules, agents, skills, hooks, and an MCP server?
* connect does the server that host is configured to start expose the Ruflo tools?
* execute does a headless turn run a SessionStart hook?
* live can that turn store and retrieve a memory value? (--live)
*
* What each CLI cannot answer is a SKIP, not a pass.
*
* node scripts/probe-host-live.mjs --host grok
* node scripts/probe-host-live.mjs --host claude
* node scripts/probe-host-live.mjs --host codex
* node scripts/probe-host-live.mjs --host all --no-execute
* node scripts/probe-host-live.mjs --host grok --live
*
* --host defaults to grok. probe-grok-host-live.mjs is that default.
* The Grok checks read this checkout's .grok/ — generate it with
* `node v3/@claude-flow/cli/bin/cli.js init --grok` (it is not committed).
* EXIT 0 no critical failures (skips and warnings allowed) 1 critical fail 2 runner error
*/
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..');
const args = process.argv.slice(2);
const JSON_OUT = args.includes('--json');
const LIVE = args.includes('--live');
const NO_EXECUTE = args.includes('--no-execute');
function flag(name) {
const i = args.indexOf(name);
if (i < 0 || i + 1 >= args.length) return null;
return args[i + 1];
}
const HOST_ARG = (flag('--host') || 'grok').toLowerCase();
const RUN_ID = `probe-${Date.now().toString(36)}`;
const MCP_NAMES = ['ruflo', 'claude-flow'];
const REQUIRED_TOOLS = [
'team_create',
'memory_store',
'memory_retrieve',
'hooks_route',
'swarm_init',
'neural_status',
];
const GROK_AGENTS = ['ruflo-architect', 'ruflo-coder', 'ruflo-tester', 'ruflo-reviewer'];
const GROK_SKILLS = ['agent-teams-grok'];
/** @type {{ host: string, id: string, domain: string, level: 'critical'|'warn'|'skip', ok: boolean, ms: number, detail: string }[]} */
const results = [];
function record(host, id, domain, level, ok, ms, detail) {
results.push({ host, id, domain, level, ok, ms, detail });
if (JSON_OUT) return;
const mark = ok ? 'PASS' : level === 'skip' ? 'SKIP' : level === 'warn' ? 'WARN' : 'FAIL';
console.log(`${mark}\t${host}\t${domain}\t${id}\t${detail}`);
}
function run(cmd, cmdArgs, timeoutMs) {
const start = Date.now();
const r = spawnSync(cmd, cmdArgs, {
cwd: REPO_ROOT,
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 20 * 1024 * 1024,
env: process.env,
});
return {
code: r.status,
stdout: r.stdout || '',
stderr: r.stderr || '',
error: r.error ? String(r.error.message || r.error) : '',
ms: Date.now() - start,
};
}
function which(bin) {
const direct = spawnSync('/bin/sh', ['-c', `command -v ${bin}`], { encoding: 'utf8' });
const path = (direct.stdout || '').trim();
return path || null;
}
function parseJson(text) {
const src = String(text || '');
for (const open of ['{', '[']) {
const start = src.indexOf(open);
if (start < 0) continue;
const close = open === '{' ? '}' : ']';
const end = src.lastIndexOf(close);
if (end <= start) continue;
try {
return JSON.parse(src.slice(start, end + 1));
} catch {
/* try the other bracket */
}
}
return null;
}
function toolNamesFromCli(cliPath) {
const listed = run(process.execPath, [cliPath, 'mcp', 'tools'], 60_000);
const names = new Set();
for (const line of `${listed.stdout}\n${listed.stderr}`.split('\n')) {
const m = line.match(/^\s{2}([a-z][a-z0-9_-]+)\s{2,}/);
if (m) names.add(m[1]);
}
return names;
}
function recordTools(host, names, filterNote) {
for (const tool of REQUIRED_TOOLS) {
if (names.has(tool)) {
record(host, `server-tool:${tool}`, 'connect', 'critical', true, 0, filterNote || 'exposed by the configured server command');
continue;
}
record(
host,
`server-tool:${tool}`,
'connect',
'critical',
false,
0,
filterNote ? `${filterNote}; ${tool} not listed` : 'missing from mcp tools',
);
}
}
function hookCommand(receipt) {
return `${process.execPath} -e ${JSON.stringify(
`require('fs').writeFileSync(${JSON.stringify(receipt)}, 'fired\\n')`,
)}`;
}
function livePrompt(host, key) {
const task = [
`Store then retrieve one Ruflo memory value.`,
`key ${JSON.stringify(key)}, value "probe-ok", namespace "host-probe".`,
'Do not edit files. Do not spawn a subagent.',
'Reply with exactly: PROBE_OK probe-ok',
];
if (host === 'grok') {
return [
'Use the ruflo MCP server through search_tool and use_tool.',
`1. search_tool for memory_store.`,
`2. use_tool ruflo__memory_store with key ${JSON.stringify(key)}, value "probe-ok", namespace "host-probe".`,
'3. use_tool ruflo__memory_retrieve with the same key and namespace.',
'Reply with exactly: PROBE_OK probe-ok',
'Do not edit files. Do not spawn a subagent.',
].join('\n');
}
return task.join('\n');
}
function finishHeadless(host, r, receipt) {
const fired = existsSync(receipt);
record(
host,
'hook:session-start',
'execute',
'critical',
fired,
r.ms,
fired ? 'SessionStart hook wrote a receipt' : 'hook did not fire, or the session never started',
);
const out = parseJson(r.stdout);
const text = String(out?.text || out?.message || r.stdout || '');
if (!LIVE) {
record(
host,
'session:ping',
'execute',
'critical',
r.code === 0 && /PING/.test(text),
0,
r.code === 0 ? 'headless session answered' : `exit=${r.code} ${(r.error || r.stderr).slice(0, 200)}`,
);
} else {
record(
host,
'live:memory-roundtrip',
'execute',
'critical',
r.code === 0 && /PROBE_OK probe-ok/.test(text),
0,
r.code === 0 ? text.trim().slice(0, 240) : `exit=${r.code} ${(r.error || r.stderr).slice(0, 240)}`,
);
}
}
const adapters = {
grok: {
binary: 'grok',
discover() {
if (!existsSync(join(REPO_ROOT, '.grok', 'config.toml'))) {
record('grok', 'surface', 'discover', 'critical', false, 0, 'no .grok/ in this checkout — run `node v3/@claude-flow/cli/bin/cli.js init --grok` first');
return;
}
const r = run('grok', ['--cwd', REPO_ROOT, 'inspect', '--json'], 60_000);
const data = parseJson(r.stdout);
if (!data) {
record('grok', 'inspect', 'discover', 'critical', false, r.ms, r.error || `exit=${r.code} no json`);
return;
}
record('grok', 'inspect:version', 'discover', 'critical', Boolean(data.grokVersion), r.ms, data.grokVersion || 'missing');
record('grok', 'inspect:trusted', 'discover', 'critical', data.projectTrusted === true, 0, data.projectTrusted ? 'folder trusted' : 'folder not trusted');
const instructions = Array.isArray(data.projectInstructions) ? data.projectInstructions : [];
const rule = instructions.find((i) => String(i.path || '').endsWith('.grok/rules/ruflo-grok.md'));
record('grok', 'inspect:rules', 'discover', 'critical', Boolean(rule), 0, rule ? rule.path : 'ruflo-grok.md not loaded');
const agents = Array.isArray(data.agents) ? data.agents : [];
for (const name of GROK_AGENTS) {
const hit = agents.find((a) => a.name === name && String(a.source?.path || '').includes(`${join('.grok', 'agents', name)}.md`));
record('grok', `inspect:agent:${name}`, 'discover', 'critical', Boolean(hit), 0, hit ? hit.source.path : 'not a project agent');
}
const skills = Array.isArray(data.skills) ? data.skills : [];
for (const name of GROK_SKILLS) {
const hit = skills.find((s) => s.name === name && String(s.source?.path || '').includes(join('.grok', 'skills')));
record('grok', `inspect:skill:${name}`, 'discover', 'critical', Boolean(hit), 0, hit ? hit.source.path : 'not loaded from .grok/skills');
}
const hooks = Array.isArray(data.hooks) ? data.hooks : [];
const teamHook = hooks.find((h) => h.event === 'subagent_stop' && String(h.target || '').includes('grok-subagent-stop-hook.mjs'));
record('grok', 'inspect:hook:subagent-stop', 'discover', 'critical', Boolean(teamHook), 0, teamHook ? teamHook.target : 'team SubagentStop hook not loaded');
const servers = Array.isArray(data.mcpServers) ? data.mcpServers : [];
const found = MCP_NAMES.map((name) => servers.find((s) => s.name === name)).filter(Boolean);
const mcp = found[0];
record('grok', 'inspect:mcp', 'discover', 'critical', Boolean(mcp), 0, mcp ? `${mcp.name} via ${mcp.source?.path || 'unknown'}` : `no ${MCP_NAMES.join(' or ')}`);
if (found.length > 1) {
record('grok', 'inspect:mcp:extra', 'discover', 'warn', false, 0, `also loaded: ${found.slice(1).map((s) => `${s.name} via ${s.source?.path || 'unknown'}`).join('; ')}`);
}
},
connect() {
const r = run('grok', ['--cwd', REPO_ROOT, 'mcp', 'doctor', 'ruflo', '--json'], 180_000);
const data = parseJson(r.stdout);
const server = data?.servers?.find((s) => MCP_NAMES.includes(s.name));
if (!server) {
record('grok', 'doctor', 'connect', 'critical', false, r.ms, r.error || r.stderr.slice(0, 240) || 'no ruflo/claude-flow server');
return;
}
const failed = (server.checks || []).filter((c) => !c.passed);
const tools = (server.checks || []).find((c) => /tools discovered/i.test(c.label));
record('grok', 'doctor:handshake', 'connect', 'critical', server.healthy === true && failed.length === 0, r.ms, failed.length ? failed.map((c) => c.label).join(', ') : `${server.target}; ${tools?.label || 'healthy'}`);
const target = String(server.target || '');
const cli = target.startsWith('node ') ? target.split(/\s+/)[1] : '';
if (!cli || !existsSync(cli)) {
record('grok', 'doctor:tool-names', 'connect', 'skip', false, 0, `host did not give a local node entry: ${target}`);
return;
}
recordTools('grok', toolNamesFromCli(cli));
},
execute() {
const receipt = join(tmpdir(), `${RUN_ID}-grok.receipt`);
const dir = join(REPO_ROOT, '.grok', 'hooks');
mkdirSync(dir, { recursive: true });
const hookPath = join(dir, `${RUN_ID}.json`);
const promptPath = join(tmpdir(), `${RUN_ID}-grok.prompt.txt`);
writeFileSync(hookPath, JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: hookCommand(receipt), timeout: 10 }] }] } }, null, 2) + '\n');
writeFileSync(promptPath, LIVE ? livePrompt('grok', `${RUN_ID}-memory`) : 'Reply with exactly: PING');
try {
const r = run('grok', ['--cwd', REPO_ROOT, '--prompt-file', promptPath, '--verbatim', '--output-format', 'json', '--max-turns', LIVE ? '8' : '1', '--always-approve'], LIVE ? 300_000 : 180_000);
finishHeadless('grok', r, receipt);
} finally {
rmSync(hookPath, { force: true });
rmSync(receipt, { force: true });
rmSync(promptPath, { force: true });
}
},
},
claude: {
binary: 'claude',
discover() {
record('claude', 'inspect', 'discover', 'skip', false, 0, 'Claude Code has no inspect --json. File presence is not evidence a session loaded rules, agents, skills, or hooks.');
},
connect() {
const r = run('claude', ['mcp', 'get', 'claude-flow'], 90_000);
const text = `${r.stdout}\n${r.stderr}`;
const missing = /No MCP server named/i.test(text);
if (missing || r.code !== 0) {
record('claude', 'mcp:claude-flow', 'connect', 'warn', false, r.ms, missing ? 'no server named claude-flow or ruflo in Claude MCP config' : text.slice(0, 240));
return;
}
record('claude', 'mcp:claude-flow', 'connect', 'critical', true, r.ms, text.replace(/\s+/g, ' ').slice(0, 240));
record('claude', 'server-tools', 'connect', 'skip', false, 0, 'claude mcp get does not report the tool list or the spawned command reliably enough to assert tool names');
},
execute() {
const receipt = join(tmpdir(), `${RUN_ID}-claude.receipt`);
const settingsPath = join(tmpdir(), `${RUN_ID}-claude-settings.json`);
const promptPath = join(tmpdir(), `${RUN_ID}-claude.prompt.txt`);
writeFileSync(settingsPath, JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: 'command', command: hookCommand(receipt), timeout: 10 }] }] } }));
writeFileSync(promptPath, LIVE ? livePrompt('claude', `${RUN_ID}-memory`) : 'Reply with exactly: PING');
try {
const r = run('claude', ['-p', '--output-format', 'json', '--settings', settingsPath, '--dangerously-skip-permissions', '--', readPrompt(promptPath)], LIVE ? 300_000 : 180_000);
finishHeadless('claude', r, receipt);
} finally {
rmSync(settingsPath, { force: true });
rmSync(receipt, { force: true });
rmSync(promptPath, { force: true });
}
},
},
codex: {
binary: 'codex',
discover() {
record('codex', 'inspect', 'discover', 'skip', false, 0, 'Codex CLI has no inspect --json for rules, agents, skills, or hooks.');
},
connect() {
const r = run('codex', ['mcp', 'list', '--json'], 30_000);
const data = parseJson(r.stdout);
if (!Array.isArray(data)) {
record('codex', 'mcp:list', 'connect', 'critical', false, r.ms, r.error || `exit=${r.code} no json`);
return;
}
const server = data.find((s) => MCP_NAMES.includes(s.name) && s.enabled !== false);
if (!server) {
record('codex', 'mcp:ruflo', 'connect', 'warn', false, r.ms, `no enabled ${MCP_NAMES.join(' or ')} in Codex MCP config`);
return;
}
const cmd = server.transport?.command;
const argv = Array.isArray(server.transport?.args) ? server.transport.args : [];
const filter = server.transport?.env?.CLAUDE_FLOW_MCP_TOOLS || '';
record('codex', 'mcp:configured', 'connect', 'critical', true, r.ms, `${server.name}: ${cmd || server.transport?.type} ${argv[0] || ''}`.trim());
record('codex', 'mcp:handshake', 'connect', 'skip', false, 0, 'codex mcp list shows configuration, not a live handshake');
const cli = argv.find((a) => String(a).endsWith('cli.js'));
if (!cli || !existsSync(cli)) {
record('codex', 'server-tools', 'connect', 'skip', false, 0, 'configured command is not a local cli.js, so tool names were not listed');
return;
}
const names = toolNamesFromCli(cli);
const groups = new Set(String(filter).split(',').map((s) => s.trim()).filter(Boolean));
const groupOf = {
team_create: 'team',
memory_store: 'memory',
memory_retrieve: 'memory',
hooks_route: 'hooks',
swarm_init: 'swarm',
neural_status: 'neural',
};
for (const tool of REQUIRED_TOOLS) {
const group = groupOf[tool];
if (groups.size && !groups.has(group)) {
record('codex', `server-tool:${tool}`, 'connect', 'skip', false, 0, `host filter CLAUDE_FLOW_MCP_TOOLS=${filter} does not include ${group}`);
continue;
}
record('codex', `server-tool:${tool}`, 'connect', 'critical', names.has(tool), 0, names.has(tool) ? 'present on the configured CLI' : 'missing');
}
},
execute() {
record('codex', 'hook:session-start', 'execute', 'skip', false, 0, 'Codex can bypass hook trust, but this adapter does not yet know a safe temp hook file Codex will load. Not counted as a pass.');
record('codex', 'session:ping', 'execute', 'skip', false, 0, 'Headless codex exec is implemented only once a hook install path is confirmed. Run is not faked.');
},
},
};
function readPrompt(path) {
return spawnSync('/bin/cat', [path], { encoding: 'utf8' }).stdout || '';
}
function runHost(id) {
const adapter = adapters[id];
const bin = which(adapter.binary);
if (!bin) {
record(id, 'binary', 'discover', 'skip', false, 0, `${adapter.binary} is not on PATH`);
return;
}
record(id, 'binary', 'discover', 'critical', true, 0, bin);
adapter.discover();
adapter.connect();
statusCheck(id);
if (!NO_EXECUTE) adapter.execute();
}
function statusCheck(id) {
const script = join(REPO_ROOT, 'scripts', 'host-statusline.mjs');
if (!existsSync(script)) {
record(id, 'statusline', 'status', 'skip', false, 0, 'scripts/host-statusline.mjs is not in this checkout');
return;
}
const r = run(process.execPath, [script, '--host', id, '--json'], 15_000);
const data = parseJson(r.stdout);
if (!data?.line) {
record(id, 'statusline', 'status', 'warn', false, r.ms, 'status snapshot produced no line');
return;
}
if (id === 'codex' || data.surface == null) {
record(id, 'statusline', 'status', 'skip', false, r.ms, `${data.line} — this host has no status row`);
return;
}
const loaded = String(data.line).startsWith('RuFlo loaded');
record(id, 'statusline', 'status', loaded ? 'critical' : 'warn', loaded, r.ms, data.line);
if (id === 'grok') {
const homeCfg = join(process.env.HOME || '', '.grok', 'config.toml');
let wired = false;
try {
// Header, [ ui."status_line" ], dotted key, or inline table.
wired = existsSync(homeCfg)
&& /^\s*\[\s*ui\s*\.\s*["']?status_line["']?\s*\]|^\s*(ui\s*\.\s*)?status_line\s*[.=]/m.test(readFileSync(homeCfg, 'utf8'));
} catch {
wired = false;
}
record(
id,
'statusline:wired',
'status',
wired ? 'critical' : 'warn',
wired,
0,
wired
? 'user ~/.grok/config.toml has [ui.status_line] (project config cannot set this)'
: 'row stays hidden until ~/.grok/config.toml has [ui.status_line]; project .grok/config.toml cannot enable it',
);
}
}
function main() {
const known = Object.keys(adapters);
if (HOST_ARG !== 'all' && !known.includes(HOST_ARG)) {
console.error(`Unknown --host ${HOST_ARG}. Use ${known.join(', ')}, or all.`);
process.exit(2);
}
const hosts = HOST_ARG === 'all' ? known : [HOST_ARG];
if (!JSON_OUT) console.log(`Host probe run=${RUN_ID} hosts=${hosts.join(',')} live=${LIVE} execute=${!NO_EXECUTE}`);
for (const id of hosts) runHost(id);
const critical = results.filter((r) => r.level === 'critical' && !r.ok);
const skips = results.filter((r) => r.level === 'skip');
const warns = results.filter((r) => r.level === 'warn' && !r.ok);
const report = {
id: RUN_ID,
when: new Date().toISOString(),
hosts,
live: LIVE,
execute: !NO_EXECUTE,
pass: results.filter((r) => r.ok).length,
fail: critical.length,
skip: skips.length,
warn: warns.length,
results,
};
const outDir = join(REPO_ROOT, 'docs', 'benchmarks');
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'host-live-latest.json'), JSON.stringify(report, null, 2) + '\n');
const lines = [
`# Host probe — ${RUN_ID}`,
'',
`**When:** ${report.when}`,
`**Hosts:** ${hosts.join(', ')}`,
`**Live model turn:** ${LIVE}`,
`**Result:** ${critical.length ? 'FAIL' : 'PASS'} — ${report.pass} passed, ${critical.length} failed, ${warns.length} warnings, ${skips.length} skipped`,
'',
'Skips are surfaces that host does not expose. They are not passes.',
'',
'| Status | Host | Domain | Id | Detail |',
'|--------|------|--------|----|--------|',
];
for (const r of results) {
const status = r.ok ? 'PASS' : r.level === 'skip' ? 'SKIP' : r.level === 'warn' ? 'WARN' : 'FAIL';
lines.push(`| ${status} | ${r.host} | ${r.domain} | \`${r.id}\` | ${r.detail.replace(/\|/g, '/')} |`);
}
lines.push('');
writeFileSync(join(outDir, 'host-live-latest.md'), lines.join('\n'));
if (JSON_OUT) console.log(JSON.stringify(report, null, 2));
else console.log(`\n${critical.length ? 'FAIL' : 'PASS'} ${report.pass} passed, ${critical.length} failed, ${warns.length} warnings, ${skips.length} skipped`);
process.exit(critical.length ? 1 : 0);
}
main();
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Build the monorepo CLI and make the shell `ruflo` command use it.
#
# Usage (from repo root or anywhere):
# ./scripts/use-local-ruflo.sh
# ./scripts/use-local-ruflo.sh --skip-build # only re-link
#
# After this:
# which ruflo → …/node_modules/ruflo → this repo's ruflo/bin/ruflo.js
# ruflo --version
# ruflo mcp tools | grep team_
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SKIP_BUILD=0
for a in "$@"; do
case "$a" in
--skip-build) SKIP_BUILD=1 ;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
;;
esac
done
if [[ "$SKIP_BUILD" -eq 0 ]]; then
echo "==> Building @claude-flow/cli (+ workspace deps)…"
(cd "$ROOT/v3" && pnpm --filter @claude-flow/cli... build)
else
echo "==> Skipping build (--skip-build)"
fi
if [[ ! -f "$ROOT/v3/@claude-flow/cli/dist/src/index.js" ]]; then
echo "error: CLI dist missing — build failed or was skipped without dist" >&2
exit 1
fi
echo "==> npm link ruflo wrapper → global PATH"
(cd "$ROOT/ruflo" && npm link)
# Prefer nvm/global node bin over older pnpm global ruflo if both exist
RUFL0_BIN="$(command -v ruflo || true)"
echo ""
echo "ruflo → ${RUFL0_BIN:-not found}"
if [[ -n "$RUFL0_BIN" ]]; then
# Resolve symlink chain target when possible
if command -v realpath >/dev/null 2>&1; then
echo "target → $(realpath "$RUFL0_BIN" 2>/dev/null || true)"
fi
ruflo --version
if ruflo mcp tools 2>/dev/null | grep -q 'team_create'; then
echo "OK: team_* tools present (local ADR-402 surface)"
else
echo "warn: team_create not listed — check CLI dist / mcp tools" >&2
fi
fi
echo ""
echo "Grok MCP: \`node $ROOT/v3/@claude-flow/cli/bin/cli.js init --grok\` in a project writes this for you."
echo "To point an existing project .grok/config.toml [mcp_servers.ruflo] at this build:"
echo " command = \"node\""
echo " args = [\"$ROOT/v3/@claude-flow/cli/bin/cli.js\", \"mcp\", \"start\"]"
echo "Then restart Grok."
@@ -0,0 +1,351 @@
/**
* `init --grok` (ADR-402) — regression coverage for the #3512 review:
* user-config merge safety, opt-in home writes, dry run, pinned MCP launch,
* Windows escaping, and the shipped status row.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
mkdtempSync,
rmSync,
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
symlinkSync,
lstatSync,
realpathSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import * as TOML from '@iarna/toml';
import {
executeGrokInit,
grokStatusLineCommand,
grokTemplatesRoot,
materializeGrokConfig,
mergeGrokStatusLine,
rufloMcpLaunch,
} from '../src/init/grok-generator.js';
const HOME = '/home/tester';
/** Existing, valid user configs that already set ui.status_line some other way. */
const ALREADY_SET: Record<string, string> = {
header: '[ui.status_line]\ntype = "command"\ncommand = "mine"\n',
spacedHeader: '[ ui.status_line ]\ntype = "command"\ncommand = "mine"\n',
quotedHeader: '[ui."status_line"]\ntype = "command"\ncommand = "mine"\n',
inlineTable: '[ui]\nstatus_line = { type = "command", command = "mine" }\n',
dottedKeys: '[ui]\nstatus_line.type = "command"\nstatus_line.command = "mine"\n',
rootDotted: 'ui.status_line.type = "command"\nui.status_line.command = "mine"\n',
};
describe('mergeGrokStatusLine (review #1)', () => {
for (const [name, text] of Object.entries(ALREADY_SET)) {
it(`leaves an existing status row alone: ${name}`, () => {
expect(TOML.parse(text)).toBeTruthy();
expect(mergeGrokStatusLine(text, HOME)).toEqual({ kind: 'already-set', ours: false });
});
}
it('appends to an unrelated config and keeps every other value and comment', () => {
const text = '# my settings\nmodel = "grok-4"\n\n[ui]\ntheme = "dark"\n\n[mcp_servers.x]\ncommand = "x"\nargs = ["a"]';
const out = mergeGrokStatusLine(text, HOME);
expect(out.kind).toBe('write');
if (out.kind !== 'write') return;
expect(out.text.startsWith(text)).toBe(true);
const parsed = TOML.parse(out.text) as any;
expect(parsed.model).toBe('grok-4');
expect(parsed.ui.theme).toBe('dark');
expect(parsed.mcp_servers.x.args).toEqual(['a']);
expect(parsed.ui.status_line).toEqual({ type: 'command', command: grokStatusLineCommand(HOME) });
});
it('falls back to the snippet when ui is an inline table (cannot be extended)', () => {
const out = mergeGrokStatusLine('ui = { theme = "dark" }\n', HOME);
expect(out.kind).toBe('unsafe');
});
it('falls back to the snippet when the file is not valid TOML', () => {
const out = mergeGrokStatusLine('[ui\nbroken = ', HOME);
expect(out.kind).toBe('unsafe');
if (out.kind === 'unsafe') expect(out.reason).toMatch(/could not parse/);
});
it('produces TOML that Python tomllib also accepts (when python3 is present)', () => {
const out = mergeGrokStatusLine('[ui]\ntheme = "dark"\n', 'C:\\Users\\Bob');
expect(out.kind).toBe('write');
if (out.kind !== 'write') return;
const py = spawnSync('python3', ['-c', 'import sys,tomllib,json; print(json.dumps(tomllib.loads(sys.stdin.read())))'], {
input: out.text,
encoding: 'utf-8',
});
if (py.error || py.status === null) return; // no python3 on this runner
if (py.stderr.includes('No module named')) return; // python < 3.11
expect(py.status, py.stderr).toBe(0);
expect(JSON.parse(py.stdout).ui.status_line.command).toBe(grokStatusLineCommand('C:\\Users\\Bob'));
});
});
describe('status row command (reviews #7, #10)', () => {
it('runs node on an absolute path, with no shell and no repo-relative script', () => {
const cmd = grokStatusLineCommand(HOME);
expect(cmd).toBe(`node "${join(HOME, '.grok', 'ruflo', 'host-statusline.mjs')}"`);
expect(cmd).not.toMatch(/\bsh\b|test -f|scripts\//);
});
});
describe('materializeGrokConfig (reviews #9, #10)', () => {
const template = readFileSync(join(grokTemplatesRoot(), 'config.toml'), 'utf-8');
it('never launches ruflo@latest', () => {
expect(template).not.toMatch(/ruflo@latest/);
const launch = rufloMcpLaunch();
const text = materializeGrokConfig(template, launch, HOME);
const parsed = TOML.parse(text) as any;
expect(parsed.mcp_servers.ruflo.command).toBe(launch.command);
expect(parsed.mcp_servers.ruflo.args).toEqual(launch.args);
expect(JSON.stringify(parsed)).not.toMatch(/@latest/);
});
it('pins a published install to its own version', () => {
const root = join(tmpdir(), 'x', 'node_modules', '@claude-flow', 'cli');
const launch = rufloMcpLaunch(root);
expect(launch.source).toBe('published');
expect(launch.args).toContain('ruflo@0.0.0');
expect(launch.args.some((a) => a.includes('latest'))).toBe(false);
});
it('escapes Windows paths into valid TOML strings', () => {
const winHome = 'C:\\Users\\Bob';
const launch = {
command: 'node',
args: ['C:\\src\\ruflo\\v3\\@claude-flow\\cli\\bin\\cli.js', 'mcp', 'start'],
source: 'checkout' as const,
version: '1.0.0',
};
// Uncomment the optional brain block so its {{HOME}} paths are parsed too.
const text = materializeGrokConfig(template.replace(/^# (\[mcp_servers\.ruvnet-brain\]|command|args|env|enabled|startup_timeout_sec|tool_timeout_sec)/gm, '$1'), launch, winHome);
const parsed = TOML.parse(text) as any;
expect(parsed.mcp_servers.ruflo.args[0]).toBe(launch.args[0]);
expect(parsed.mcp_servers['ruvnet-brain'].env.KB_DIR).toBe('C:\\Users\\Bob/.cache/ruvnet-brain/kb');
});
});
describe('executeGrokInit', () => {
let dir: string;
let home: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'ruflo-grok-init-'));
home = mkdtempSync(join(tmpdir(), 'ruflo-grok-home-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
it('does not touch the home directory unless asked (review #2)', () => {
const r = executeGrokInit({ targetDir: dir, homeDir: home });
expect(r.success, r.errors.join('\n')).toBe(true);
expect(readdirSync(home)).toEqual([]);
expect(r.userConfig.action).toBe('not-requested');
expect(r.userConfig.snippet).toMatch(/\[ui\.status_line\]/);
expect(r.filesCreated.every((f) => f.startsWith(dir))).toBe(true);
// Project config is valid TOML and pinned
const cfg = TOML.parse(readFileSync(join(dir, '.grok', 'config.toml'), 'utf-8')) as any;
expect(cfg.mcp_servers.ruflo.args.join(' ')).not.toMatch(/latest/);
});
it('dry run writes nothing and lists what it would write (review #2)', () => {
const r = executeGrokInit({ targetDir: dir, homeDir: home, dryRun: true, statusLine: true });
expect(r.success).toBe(true);
expect(readdirSync(dir)).toEqual([]);
expect(readdirSync(home)).toEqual([]);
expect(r.userConfig.action).toBe('would-write');
expect(r.filesCreated).toContain(join(home, '.grok', 'config.toml'));
expect(r.filesCreated).toContain(join(dir, '.grok', 'config.toml'));
});
it('--grok-statusline merges into an existing user config and lists the path', () => {
mkdirSync(join(home, '.grok'), { recursive: true });
writeFileSync(join(home, '.grok', 'config.toml'), '# keep me\n[ui]\ntheme = "dark"\n');
const r = executeGrokInit({ targetDir: dir, homeDir: home, statusLine: true });
expect(r.success, r.errors.join('\n')).toBe(true);
expect(r.userConfig.action).toBe('merged');
expect(r.filesCreated).toContain(join(home, '.grok', 'config.toml'));
const text = readFileSync(join(home, '.grok', 'config.toml'), 'utf-8');
expect(text.startsWith('# keep me\n[ui]\ntheme = "dark"\n')).toBe(true);
const parsed = TOML.parse(text) as any;
expect(parsed.ui.status_line.command).toBe(grokStatusLineCommand(home));
expect(existsSync(join(home, '.grok', 'ruflo', 'host-statusline.mjs'))).toBe(true);
// A second run is a no-op on the config
const again = executeGrokInit({ targetDir: dir, homeDir: home, statusLine: true, force: true });
expect(again.userConfig.action).toBe('already-set');
expect(readFileSync(join(home, '.grok', 'config.toml'), 'utf-8')).toBe(text);
});
it('--grok-statusline leaves a foreign status row alone and installs nothing', () => {
mkdirSync(join(home, '.grok'), { recursive: true });
const original = '[ ui.status_line ]\ntype = "command"\ncommand = "mine"\n';
writeFileSync(join(home, '.grok', 'config.toml'), original);
const r = executeGrokInit({ targetDir: dir, homeDir: home, statusLine: true });
expect(r.userConfig.action).toBe('already-set');
expect(r.userConfig.reason).toMatch(/its own \[ui\.status_line\]/);
expect(readFileSync(join(home, '.grok', 'config.toml'), 'utf-8')).toBe(original);
expect(existsSync(join(home, '.grok', 'ruflo'))).toBe(false);
expect(r.filesCreated.some((f) => f.startsWith(home))).toBe(false);
});
it('--grok-statusline never rewrites a config it cannot merge safely', () => {
mkdirSync(join(home, '.grok'), { recursive: true });
const original = 'ui = { theme = "dark" }\n';
writeFileSync(join(home, '.grok', 'config.toml'), original);
const r = executeGrokInit({ targetDir: dir, homeDir: home, statusLine: true });
expect(r.success).toBe(true);
expect(r.userConfig.action).toBe('snippet');
expect(r.userConfig.reason).toMatch(/would not parse/);
expect(readFileSync(join(home, '.grok', 'config.toml'), 'utf-8')).toBe(original);
});
it('ships a status row that reports "loaded" after init (review #6)', () => {
executeGrokInit({ targetDir: dir, homeDir: home });
const r = spawnSync(process.execPath, [join(dir, 'scripts', 'host-statusline.mjs'), '--json'], {
cwd: dir,
env: { ...process.env, HOME: home, USERPROFILE: home },
encoding: 'utf-8',
input: '',
});
expect(r.status, r.stderr).toBe(0);
const snap = JSON.parse(r.stdout);
expect(snap.loaded).toMatchObject({ mcp: true, rules: true, agents: 4, skills: 1, hook: true });
// Only folder trust (a Grok-side setting) is left
expect(snap.line).toMatch(/^RuFlo │ missing untrusted/);
});
it('host-statusline.mjs parses real TOML instead of text-matching (round-2 review, minor item 3)', () => {
executeGrokInit({ targetDir: dir, homeDir: home });
const runStatusline = () =>
JSON.parse(
spawnSync(process.execPath, [join(dir, 'scripts', 'host-statusline.mjs'), '--json'], {
cwd: dir,
env: { ...process.env, HOME: home, USERPROFILE: home },
encoding: 'utf-8',
input: '',
}).stdout,
);
// Node's spawn cwd is resolved to the real path (e.g. macOS /var is a
// symlink to /private/var) before the child ever sees process.cwd(), so
// the section key must match that resolved path, not the tmpdir's own.
const root = realpathSync(dir);
// A commented-out `trusted = true` must not read as trusted (a
// substring/regex match on the section body would say yes).
mkdirSync(join(home, '.grok'), { recursive: true });
writeFileSync(join(home, '.grok', 'trusted_folders.toml'), `[folders."${root}"]\n# trusted = true\n`);
expect(runStatusline().loaded.trusted).toBe(false);
// A genuine (uncommented) trusted = true in the same section shape
// still works — this isn't just rejecting everything.
writeFileSync(join(home, '.grok', 'trusted_folders.toml'), `[folders."${root}"]\ntrusted = true\n`);
expect(runStatusline().loaded.trusted).toBe(true);
// An mcp_servers.ruflo table that exists but is explicitly disabled
// must not count as "loaded" (a substring match on `[mcp_servers.name]`
// alone would say yes regardless of `enabled`).
writeFileSync(
join(dir, '.grok', 'config.toml'),
'[mcp_servers.ruflo]\ncommand = "node"\nargs = ["x"]\nenabled = false\n',
);
expect(runStatusline().loaded.mcp).toBe(false);
});
it('treats a ] inside a quoted trusted-folder key as part of the path (round-3 review)', () => {
executeGrokInit({ targetDir: dir, homeDir: home });
const weird = join(dir, 'a]b');
mkdirSync(weird, { recursive: true });
const root = realpathSync(weird);
mkdirSync(join(home, '.grok'), { recursive: true });
writeFileSync(join(home, '.grok', 'trusted_folders.toml'), `[folders."${root}"]\ntrusted = true\n`);
const r = spawnSync(process.execPath, [join(grokTemplatesRoot(), 'scripts', 'host-statusline.mjs'), '--json'], {
cwd: weird,
env: { ...process.env, HOME: home, USERPROFILE: home },
encoding: 'utf-8',
input: '',
});
expect(r.status, r.stderr).toBe(0);
expect(JSON.parse(r.stdout).loaded.trusted).toBe(true);
});
it('writes through a symlinked ~/.grok/config.toml instead of replacing it (round-2 review, minor item 1)', () => {
// A dotfiles-managed setup often symlinks ~/.grok/config.toml at a real
// file living elsewhere (e.g. a dotfiles repo checkout).
const realTarget = join(home, 'dotfiles-config.toml');
writeFileSync(realTarget, '# keep me\n[ui]\ntheme = "dark"\n');
mkdirSync(join(home, '.grok'), { recursive: true });
const symlinkPath = join(home, '.grok', 'config.toml');
symlinkSync(realTarget, symlinkPath);
const r = executeGrokInit({ targetDir: dir, homeDir: home, statusLine: true });
expect(r.success, r.errors.join('\n')).toBe(true);
// The symlink itself must survive, still pointing at the same target.
expect(lstatSync(symlinkPath).isSymbolicLink()).toBe(true);
expect(realpathSync(symlinkPath)).toBe(realpathSync(realTarget));
// The status row was written THROUGH the symlink, into the real file.
const written = readFileSync(realTarget, 'utf-8');
expect(written.startsWith('# keep me\n[ui]\ntheme = "dark"\n')).toBe(true);
const parsed = TOML.parse(written) as any;
expect(parsed.ui.status_line.command).toBe(grokStatusLineCommand(home));
});
it('rejects an existing user config with an unserializable value before writing anything (round-2 review, minor item 2)', () => {
mkdirSync(join(home, '.grok'), { recursive: true });
const dest = join(home, '.grok', 'config.toml');
// A TOML integer literal above 2^53 parses to a BigInt (@iarna/toml),
// which crashes JSON.stringify — previously discovered only as the LAST
// step of init, after project files were already written.
const original = '# keep me\ngiant = 99999999999999999999\n';
writeFileSync(dest, original);
const r = executeGrokInit({ targetDir: dir, homeDir: home, statusLine: true });
expect(r.success).toBe(false);
expect(r.errors.join('\n')).toMatch(/config\.toml/);
expect(r.errors.join('\n')).toMatch(/giant/);
expect(r.errors.join('\n')).toMatch(/BigInt|serialize/i);
// Nothing was written: not the user config, not a single project file.
expect(readFileSync(dest, 'utf-8')).toBe(original);
expect(r.filesCreated).toEqual([]);
expect(existsSync(join(dir, '.grok'))).toBe(false);
expect(readdirSync(dir)).toEqual([]);
});
it('keeps init working when a BigInt sits beside an existing status row (round-3 review)', () => {
mkdirSync(join(home, '.grok'), { recursive: true });
const dest = join(home, '.grok', 'config.toml');
// Nothing is serialized when the user already has [ui.status_line], so
// the integer must not fail the whole init.
const original = 'giant = 99999999999999999999\n[ui.status_line]\ntype = "command"\ncommand = "mine"\n';
writeFileSync(dest, original);
const r = executeGrokInit({ targetDir: dir, homeDir: home, statusLine: true });
expect(r.success, r.errors.join('\n')).toBe(true);
expect(r.userConfig.action).toBe('already-set');
expect(readFileSync(dest, 'utf-8')).toBe(original);
expect(existsSync(join(dir, '.grok', 'config.toml'))).toBe(true);
});
it('copies the scripts byte-for-byte from templates (no second copy to drift)', () => {
executeGrokInit({ targetDir: dir, homeDir: home });
for (const f of ['grok-team-bus.mjs', 'grok-team-store.mjs', 'grok-subagent-stop-hook.mjs', 'host-statusline.mjs']) {
expect(readFileSync(join(dir, 'scripts', f), 'utf-8')).toBe(
readFileSync(join(grokTemplatesRoot(), 'scripts', f), 'utf-8'),
);
}
});
});
@@ -0,0 +1,448 @@
/**
* ADR-402 team_* MCP tools + the grok-team-bus.mjs CLI they share a store with.
* Regression coverage for the #3512 review (scoping, locking, lifecycle, hook).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs, {
mkdtempSync,
rmSync,
existsSync,
readFileSync,
readdirSync,
mkdirSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { teamTools } from '../src/mcp-tools/team-tools.js';
const STORE = join(resolve(__dirname, '..', 'templates', 'grok', 'scripts'), 'grok-team-store.mjs');
const SCRIPTS = resolve(__dirname, '..', 'templates', 'grok', 'scripts');
const BUS = join(SCRIPTS, 'grok-team-bus.mjs');
const HOOK = join(SCRIPTS, 'grok-subagent-stop-hook.mjs');
type Result = { success: boolean; error?: string } & Record<string, any>;
function tool(name: string) {
const t = teamTools.find((x) => x.name === name);
if (!t) throw new Error(`missing tool ${name}`);
return (input: Record<string, unknown>) => t.handler(input) as Promise<Result>;
}
function runNode(
script: string,
args: string[],
opts: { cwd: string; stdin?: string; env?: Record<string, string> },
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((done) => {
const child = spawn(process.execPath, [script, ...args], {
cwd: opts.cwd,
env: { ...process.env, CLAUDE_PROJECT_DIR: opts.cwd, TEAM_NAME: '', SUBAGENT_NAME: '', ...opts.env },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (c) => (stdout += c));
child.stderr.on('data', (c) => (stderr += c));
child.on('close', (code) => done({ code: code ?? 1, stdout, stderr }));
child.stdin.end(opts.stdin ?? '');
});
}
function readTeam(cwd: string, name: string) {
return JSON.parse(readFileSync(join(cwd, '.claude-flow', 'teams', name, 'team.json'), 'utf-8'));
}
describe('teamTools (ADR-402)', () => {
let cwd: string;
let prev: string | undefined;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ruflo-team-'));
prev = process.env.CLAUDE_FLOW_CWD;
process.env.CLAUDE_FLOW_CWD = cwd;
});
afterEach(() => {
if (prev === undefined) delete process.env.CLAUDE_FLOW_CWD;
else process.env.CLAUDE_FLOW_CWD = prev;
rmSync(cwd, { recursive: true, force: true });
});
it('create → plan → spawn → send → inbox → on_stop → shutdown', async () => {
const create = await tool('team_create')({ name: 'demo', maxAgents: 4, host: 'grok' });
expect(create.success).toBe(true);
expect(existsSync(join(cwd, '.claude-flow', 'teams', 'demo', 'team.json'))).toBe(true);
expect((await tool('team_plan')({ team: 'demo', steps: ['architect', 'developer'] })).success).toBe(true);
const spawned = await tool('team_spawn')({
team: 'demo',
agent: 'architect',
role: 'architect',
prompt: 'Design the feature',
next: ['developer'],
});
expect(spawned.success).toBe(true);
const grok = spawned.spawnPlan.host.grok;
expect(Object.keys(grok.spawn).sort()).toEqual(['background', 'description', 'isolation']);
expect(grok.spawn).toEqual({ description: 'architect:architect@demo', background: true, isolation: 'none' });
expect(grok.advisory.capability_mode).toBe('read-only');
const send = await tool('team_send')({
team: 'demo',
to: 'developer',
from: 'architect',
summary: 'design',
message: 'Use layered architecture',
});
expect(send.success).toBe(true);
// Pre-spawn handoff: status counts developer's mailbox before registration
const early = await tool('team_status')({ team: 'demo' });
expect(early.pendingMail.developer).toBe(1);
await tool('team_spawn')({ team: 'demo', agent: 'developer', role: 'developer', prompt: 'Implement' });
const inbox = await tool('team_inbox')({ team: 'demo', agent: 'developer', peek: true });
expect(inbox.messages).toHaveLength(1);
const onStop = await tool('team_on_stop')({ team: 'demo', agent: 'architect' });
expect(onStop.assign.agent).toBe('developer');
expect((await tool('team_shutdown')({ team: 'demo' })).success).toBe(true);
expect(readTeam(cwd, 'demo').status).toBe('shutdown');
});
it('rejects invalid team names', async () => {
expect((await tool('team_create')({ name: '../evil' })).success).toBe(false);
expect((await tool('team_inbox')({ team: '../evil', agent: 'a' })).success).toBe(false);
});
it('scopes mailboxes and status to one team (review #3)', async () => {
await tool('team_create')({ name: 'alpha' });
await tool('team_create')({ name: 'beta' });
await tool('team_spawn')({ team: 'alpha', agent: 'coder' });
await tool('team_spawn')({ team: 'beta', agent: 'coder' });
await tool('team_send')({ team: 'alpha', to: 'coder', message: 'only for alpha' });
const beta = await tool('team_inbox')({ team: 'beta', agent: 'coder' });
expect(beta.success).toBe(true);
expect(beta.messages).toEqual([]);
expect((await tool('team_status')({ team: 'beta' })).pendingMail).toEqual({ coder: 0 });
expect((await tool('team_status')({ team: 'alpha' })).pendingMail).toEqual({ coder: 1 });
const alpha = await tool('team_inbox')({ team: 'alpha', agent: 'coder' });
expect(alpha.messages.map((m: { content: string }) => m.content)).toEqual(['only for alpha']);
expect((await tool('team_inbox')({ agent: 'coder' })).success).toBe(false); // team is required
});
it('registers all 30 members when spawns race across processes (review #4)', async () => {
await tool('team_create')({ name: 'race', maxAgents: 50 });
const cli = Array.from({ length: 15 }, (_, i) =>
runNode(BUS, ['spawn', '--root', cwd, '--team', 'race', '--agent', `cli-${i}`], { cwd }),
);
const mcp = Array.from({ length: 15 }, (_, i) => tool('team_spawn')({ team: 'race', agent: `mcp-${i}` }));
const results = await Promise.all([...cli, ...mcp]);
for (const r of results) {
if ('code' in r) expect(r.code, r.stdout + r.stderr).toBe(0);
else expect(r.success, r.error).toBe(true);
}
const team = readTeam(cwd, 'race');
expect(Object.keys(team.members)).toHaveLength(30);
expect(existsSync(join(cwd, '.claude-flow', 'teams', 'race', 'team.lock'))).toBe(false);
expect(readdirSync(join(cwd, '.claude-flow', 'teams', 'race')).filter((f) => f.endsWith('.tmp'))).toEqual([]);
}, 60_000);
it('breaks a stale lock left by a crashed holder, and releases its own', async () => {
await tool('team_create')({ name: 'stale' });
const lock = join(cwd, '.claude-flow', 'teams', 'stale', 'team.lock');
writeFileSync(lock, 'crashed-holder');
const old = new Date(Date.now() - 120_000);
utimesSync(lock, old, old);
expect((await tool('team_spawn')({ team: 'stale', agent: 'a' })).success).toBe(true);
expect(existsSync(lock)).toBe(false);
expect(readdirSync(join(cwd, '.claude-flow', 'teams', 'stale')).filter((f) => f.includes('.stale.'))).toEqual([]);
});
it('re-verifies a stale lock before breaking it, and never renames one that changed underneath it (review N2)', async () => {
// Reproduces the round-2 review's race: waiter W1 reads a stale lock's
// token; meanwhile another waiter breaks that same lock and a third
// party re-acquires a fresh, live one at the same path. A buggy
// breakStaleLock would rename that live lock away regardless (and, in a
// real multi-process run, risk a fourth party grabbing the vacated slot
// before it's put back — two holders at once). The fix re-verifies the
// token immediately before renaming and backs off if it changed.
await tool('team_create')({ name: 'race2' });
const dir = join(cwd, '.claude-flow', 'teams', 'race2');
const lock = join(dir, 'team.lock');
writeFileSync(lock, 'crashed-holder', { encoding: 'utf8' });
const old = new Date(Date.now() - 120_000);
utimesSync(lock, old, old);
const store: any = await import(pathToFileURL(STORE).href);
const realReadFileSync: any = fs.readFileSync;
const realRenameSync: any = fs.renameSync;
let renamed = false;
let triggered = false;
(fs as any).readFileSync = (...args: any[]) => {
const result = realReadFileSync(...args);
if (!triggered && args[0] === lock && result === 'crashed-holder') {
triggered = true;
// Simulate another waiter breaking this exact lock and a third
// party re-acquiring a fresh one, right after we read the stale
// token but before we act on it.
writeFileSync(lock, 'C-token', { encoding: 'utf8' });
const now = new Date();
utimesSync(lock, now, now);
}
return result;
};
(fs as any).renameSync = (...args: any[]) => {
renamed = true;
return realRenameSync(...args);
};
try {
store.breakStaleLock(lock, 'our-break-token');
} finally {
(fs as any).readFileSync = realReadFileSync;
(fs as any).renameSync = realRenameSync;
}
expect(triggered).toBe(true);
expect(renamed).toBe(false); // never touched the lock once it saw it had changed
expect(readFileSync(lock, 'utf8')).toBe('C-token'); // untouched, not clobbered/restored
expect(readdirSync(dir).filter((f) => f.includes('.stale.'))).toEqual([]);
});
it('does not break a stale lock whose holder pid is still alive (round-3 review)', async () => {
await tool('team_create')({ name: 'alive' });
const dir = join(cwd, '.claude-flow', 'teams', 'alive');
const lock = join(dir, 'team.lock');
const store: any = await import(pathToFileURL(STORE).href);
const live = `${process.pid}.${Date.now()}.live`;
writeFileSync(lock, live);
const old = new Date(Date.now() - 120_000);
utimesSync(lock, old, old);
store.breakStaleLock(lock, 'waiter-token');
expect(readFileSync(lock, 'utf8')).toBe(live);
const deadPid = 2 ** 22 + 12345;
writeFileSync(lock, `${deadPid}.${Date.now()}.dead`);
utimesSync(lock, old, old);
store.breakStaleLock(lock, 'waiter-token');
expect(existsSync(lock)).toBe(false);
expect(readdirSync(dir).filter((f) => f.includes('.stale.'))).toEqual([]);
});
it('breaks a stale lock safely with several real concurrent waiters, no lost updates (review N2/N3)', async () => {
await tool('team_create')({ name: 'race-stale', maxAgents: 10 });
const dir = join(cwd, '.claude-flow', 'teams', 'race-stale');
const lock = join(dir, 'team.lock');
writeFileSync(lock, 'crashed-holder', { encoding: 'utf8' });
const old = new Date(Date.now() - 120_000);
utimesSync(lock, old, old);
const results = await Promise.all(
Array.from({ length: 3 }, (_, i) =>
runNode(BUS, ['spawn', '--root', cwd, '--team', 'race-stale', '--agent', `w${i}`], { cwd }),
),
);
for (const r of results) expect(r.code, r.stdout + r.stderr).toBe(0);
const team = readTeam(cwd, 'race-stale');
expect(Object.keys(team.members).sort()).toEqual(['w0', 'w1', 'w2']);
expect(existsSync(lock)).toBe(false);
expect(readdirSync(dir).filter((f) => f.includes('.stale.') || f.endsWith('.tmp'))).toEqual([]);
}, 30_000);
it('lock-wait timeout covers the stale window, so a wait never gives up before the lock could be broken (review N3)', async () => {
const store: any = await import(pathToFileURL(STORE).href);
// Import the same constants indirectly: a lock that just turned stale
// must still be breakable well within one wait attempt's own timeout —
// otherwise every spawn in the gap between "give up" and "now stale"
// fails even though nothing is actually stuck.
await tool('team_create')({ name: 'timing' });
const dir = join(cwd, '.claude-flow', 'teams', 'timing');
const lock = join(dir, 'team.lock');
writeFileSync(lock, 'crashed-holder', { encoding: 'utf8' });
// Just past the stale threshold used in grok-team-store.mjs (4s) — not
// past the wait timeout (4.5s), so this must succeed, not time out.
const justStale = new Date(Date.now() - 4_050);
utimesSync(lock, justStale, justStale);
const result = await store.withTeamLock(cwd, 'timing', () => 'held');
expect(result).toBe('held');
});
it('clears the old mailbox when force-recreating a team (review N4)', async () => {
await tool('team_create')({ name: 'reborn' });
await tool('team_spawn')({ team: 'reborn', agent: 'old-member' });
await tool('team_send')({ team: 'reborn', to: 'old-member', message: 'from the old incarnation' });
const mailbox = join(cwd, '.claude-flow', 'teams', 'reborn', 'mailbox');
expect(readdirSync(join(mailbox, 'old-member')).filter((f) => f.endsWith('.json'))).toHaveLength(1);
const recreated = await tool('team_create')({ name: 'reborn', force: true });
expect(recreated.success).toBe(true);
expect(existsSync(join(mailbox, 'old-member'))).toBe(false); // wiped, not carried over
expect(readTeam(cwd, 'reborn').members).toEqual({});
await tool('team_spawn')({ team: 'reborn', agent: 'new-member' });
expect((await tool('team_inbox')({ team: 'reborn', agent: 'new-member' })).messages).toEqual([]);
});
it('closes the team on shutdown and enforces maxAgents (review #8)', async () => {
await tool('team_create')({ name: 'small', maxAgents: 2 });
expect((await tool('team_spawn')({ team: 'small', agent: 'a' })).success).toBe(true);
expect((await tool('team_spawn')({ team: 'small', agent: 'b' })).success).toBe(true);
const full = await tool('team_spawn')({ team: 'small', agent: 'c' });
expect(full.success).toBe(false);
expect(full.error).toMatch(/full \(maxAgents=2\)/);
// Re-spawning an existing member is an update, not a new seat
expect((await tool('team_spawn')({ team: 'small', agent: 'a', prompt: 'again' })).success).toBe(true);
await tool('team_send')({ team: 'small', to: 'a', message: 'before close' });
await tool('team_shutdown')({ team: 'small' });
for (const [name, input] of [
['team_spawn', { team: 'small', agent: 'd' }],
['team_send', { team: 'small', to: 'a', message: 'after close' }],
['team_broadcast', { team: 'small', message: 'after close' }],
['team_plan', { team: 'small', steps: ['a'] }],
] as const) {
const r = await tool(name)(input);
expect(r.success, name).toBe(false);
expect(r.error).toMatch(/is shutdown/);
}
// Remaining mail can still be drained
expect((await tool('team_inbox')({ team: 'small', agent: 'a' })).messages).toHaveLength(1);
});
it('rejects a broadcast with no members, delivers one with members (review minor)', async () => {
await tool('team_create')({ name: 'bc' });
const empty = await tool('team_broadcast')({ team: 'bc', message: 'anyone?' });
expect(empty.success).toBe(false);
expect(empty.error).toMatch(/no members/);
await tool('team_spawn')({ team: 'bc', agent: 'x' });
await tool('team_spawn')({ team: 'bc', agent: 'y' });
expect((await tool('team_broadcast')({ team: 'bc', message: 'hi all' })).recipients).toEqual(['x', 'y']);
expect((await tool('team_inbox')({ team: 'bc', agent: 'y' })).messages[0].type).toBe('broadcast');
});
it('validates priority and sorts numerically (review minor)', async () => {
await tool('team_create')({ name: 'prio' });
await tool('team_spawn')({ team: 'prio', agent: 'p' });
for (const bad of ['abc', -1, 1.5, 1000, '1e2', '0x10', '0o20', '0b101']) {
const r = await tool('team_send')({ team: 'prio', to: 'p', message: 'm', priority: bad });
expect(r.success, String(bad)).toBe(false);
expect(r.error).toMatch(/priority must be an integer/);
}
await tool('team_send')({ team: 'prio', to: 'p', message: 'ten', priority: 10 });
await tool('team_send')({ team: 'prio', to: 'p', message: 'two', priority: 2 });
await tool('team_send')({ team: 'prio', to: 'p', message: 'zero', priority: '0' });
const files = readdirSync(join(cwd, '.claude-flow', 'teams', 'prio', 'mailbox', 'p')).filter((f) => f.endsWith('.json'));
expect(files.every((f) => /^\d{3}_msg_/.test(f))).toBe(true);
const inbox = await tool('team_inbox')({ team: 'prio', agent: 'p' });
expect(inbox.messages.map((m: { content: string }) => m.content)).toEqual(['zero', 'two', 'ten']);
});
it('refuses a symlinked mailbox directory (review minor)', async () => {
await tool('team_create')({ name: 'sym' });
const outside = mkdtempSync(join(tmpdir(), 'ruflo-outside-'));
try {
const box = join(cwd, '.claude-flow', 'teams', 'sym', 'mailbox');
mkdirSync(box, { recursive: true });
symlinkSync(outside, join(box, 'victim'), 'dir');
const r = await tool('team_send')({ team: 'sym', to: 'victim', message: 'escape?' });
expect(r.success).toBe(false);
expect(r.error).toMatch(/not a real directory/);
expect(readdirSync(outside)).toEqual([]);
expect((await tool('team_inbox')({ team: 'sym', agent: 'victim' })).success).toBe(false);
} finally {
rmSync(outside, { recursive: true, force: true });
}
});
it('two concurrent drains split the mail without ENOENT (review minor)', async () => {
await tool('team_create')({ name: 'drain' });
await tool('team_spawn')({ team: 'drain', agent: 'd' });
for (let i = 0; i < 40; i++) {
await tool('team_send')({ team: 'drain', to: 'd', message: `m${i}` });
}
const [a, b] = await Promise.all([
runNode(BUS, ['inbox', '--root', cwd, '--team', 'drain', '--agent', 'd'], { cwd }),
runNode(BUS, ['inbox', '--root', cwd, '--team', 'drain', '--agent', 'd'], { cwd }),
]);
expect(a.code, a.stdout).toBe(0);
expect(b.code, b.stdout).toBe(0);
const got = [...JSON.parse(a.stdout).messages, ...JSON.parse(b.stdout).messages].map((m) => m.content);
expect(got).toHaveLength(40);
expect(new Set(got).size).toBe(40);
}, 30_000);
});
describe('SubagentStop hook (review #5)', () => {
let cwd: string;
let prev: string | undefined;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ruflo-hook-'));
prev = process.env.CLAUDE_FLOW_CWD;
process.env.CLAUDE_FLOW_CWD = cwd;
});
afterEach(() => {
if (prev === undefined) delete process.env.CLAUDE_FLOW_CWD;
else process.env.CLAUDE_FLOW_CWD = prev;
rmSync(cwd, { recursive: true, force: true });
});
it('advances the plan from the spawn description team_spawn returns', async () => {
await tool('team_create')({ name: 'pipe' });
await tool('team_plan')({ team: 'pipe', steps: ['alice', 'bob'] });
const plan = await tool('team_spawn')({ team: 'pipe', agent: 'alice', role: 'coder' });
const description = plan.spawnPlan.host.grok.spawn.description;
expect(description).toBe('coder:alice@pipe');
const r = await runNode(HOOK, [], { cwd, stdin: JSON.stringify({ description }) });
expect(r.code, r.stderr).toBe(0);
const team = readTeam(cwd, 'pipe');
expect(team.plan.steps.map((s: { status: string }) => s.status)).toEqual(['done', 'ready']);
expect(team.members.alice.status).toBe('idle');
});
it('ignores non-team subagents and reports failures on stderr', async () => {
await tool('team_create')({ name: 'one' });
await tool('team_create')({ name: 'two' });
await tool('team_spawn')({ team: 'one', agent: 'dup' });
await tool('team_spawn')({ team: 'two', agent: 'dup' });
const unrelated = await runNode(HOOK, [], { cwd, stdin: JSON.stringify({ description: 'Explore the repo for X' }) });
expect(unrelated.code).toBe(0);
expect(unrelated.stderr).toBe('');
const ambiguous = await runNode(HOOK, [], { cwd, stdin: JSON.stringify({ description: 'coder:dup' }) });
expect(ambiguous.code).toBe(1);
expect(ambiguous.stderr).toMatch(/several active teams \(one, two\)/);
const missing = await runNode(HOOK, [], { cwd, stdin: JSON.stringify({ description: 'coder:dup@nope' }) });
expect(missing.code).toBe(1);
expect(missing.stderr).toMatch(/Team "nope" not found/);
});
it('does not lose updates when hooks fire in parallel', async () => {
await tool('team_create')({ name: 'par', maxAgents: 20 });
const names = Array.from({ length: 12 }, (_, i) => `w${i}`);
for (const n of names) await tool('team_spawn')({ team: 'par', agent: n });
const runs = await Promise.all(
names.map((n) => runNode(HOOK, [], { cwd, stdin: JSON.stringify({ description: `coder:${n}@par` }) })),
);
for (const r of runs) expect(r.code, r.stderr).toBe(0);
const team = readTeam(cwd, 'par');
expect(names.every((n) => team.members[n].status === 'idle' && team.members[n].lastStopAt)).toBe(true);
}, 30_000);
});
+1
View File
@@ -76,6 +76,7 @@
"!dist/**/*.tsbuildinfo",
"bin",
"scripts/postinstall.cjs",
"templates",
".claude",
"plugins",
"README.md",
+134
View File
@@ -18,6 +18,7 @@ import {
FULL_INIT_OPTIONS,
type InitOptions,
} from '../init/index.js';
import { executeGrokInit } from '../init/grok-generator.js';
import {
ENROLLMENT_SCREEN,
recordEnrollmentOutcome,
@@ -392,6 +393,107 @@ async function maybeInstallSkillsSh(ctx: CommandContext): Promise<void> {
}
}
// Grok Build host initialization (ADR-402)
async function initGrokAction(
ctx: CommandContext,
options: { force: boolean; statusLine: boolean; dryRun: boolean }
): Promise<CommandResult> {
const { force, statusLine, dryRun } = options;
output.writeln();
output.writeln(output.bold(dryRun ? 'Grok Build init (dry run — nothing is written)' : 'Initializing RuFlo for Grok Build'));
output.writeln();
try {
const result = executeGrokInit({
targetDir: ctx.cwd,
force,
docs: true,
statusLine,
dryRun,
});
if (!result.success) {
output.printError('Grok initialization failed');
for (const err of result.errors) {
output.printError(err);
}
return { success: false, exitCode: 1, message: 'Grok init failed' };
}
output.printBox(
[
`.grok/config.toml — project MCP (ruflo) + permissions`,
`.grok/rules/ruflo-grok.md — host doctrine / tool map`,
`.grok/agents/ — ruflo-architect/coder/tester/reviewer`,
`.grok/skills/ — agent-teams-grok`,
`.grok/hooks/ — SubagentStop advances the team plan`,
`scripts/grok-team-bus.mjs (+ grok-team-store.mjs) — ADR-402 team bus CLI`,
`scripts/host-statusline.mjs — "what is loaded" row`,
`docs/grok/README.md — operator guide`,
].join('\n'),
'Grok Build Integration (ADR-402)'
);
output.writeln();
const verb = dryRun ? 'Would write' : 'Wrote';
output.writeln(output.bold(`${verb} (${result.filesCreated.length}):`));
output.printList(result.filesCreated.map((f) => (path.relative(ctx.cwd, f).startsWith('..') ? f : path.relative(ctx.cwd, f))));
if (result.filesSkipped.length > 0) {
output.printInfo(`Skipped ${result.filesSkipped.length} existing file(s)${force ? '' : ' — use --force to overwrite'}`);
}
output.writeln();
const mcp = result.mcp;
output.printInfo(
mcp.source === 'published'
? `MCP server pinned to ruflo@${mcp.version} (the release that ships the team_* tools)`
: `MCP server points at this checkout: ${mcp.args[0]} — rebuild it before use, and re-run init from a published release before committing .grok/config.toml`
);
const uc = result.userConfig;
if (uc.action === 'not-requested' || uc.action === 'snippet') {
output.writeln();
output.writeln(
uc.action === 'snippet'
? output.bold(`Did not change ${uc.path}: ${uc.reason}`)
: output.bold('Optional status row (Grok reads it only from your user config):')
);
output.writeln(output.dim(` Add this to ${uc.path}, or re-run with --grok-statusline to have init merge it`));
output.writeln(output.dim(` (that also installs the script it runs under ~/.grok/ruflo/):`));
output.writeln(uc.snippet.trimEnd().split('\n').map((l) => ` ${l}`).join('\n'));
} else if (uc.action === 'already-set') {
output.printInfo(uc.reason || `${uc.path} already has the Ruflo status row; refreshed the script it runs`);
}
output.writeln();
output.writeln(output.bold('Next steps:'));
output.printList([
'Trust this folder in Grok (/hooks-trust or grok --trust) so MCP, hooks, skills, and rules load together',
'Restart Grok so .grok/config.toml is applied',
'Read docs/grok/README.md — spawn contract checked on Grok Build 1.0.41',
'Verify: grok mcp list && grok mcp doctor ruflo',
'Optional: npx ruvnet-brain@latest then enable ruvnet-brain in .grok/config.toml',
'Teams: team_create / team_spawn MCP tools (or node scripts/grok-team-bus.mjs)',
]);
output.writeln();
return {
success: true,
data: {
adapter: 'grok',
dryRun,
filesCreated: result.filesCreated,
filesSkipped: result.filesSkipped,
mcp: result.mcp,
userConfig: { action: uc.action, path: uc.path },
},
};
} catch (e) {
output.printError(`Grok initialization failed: ${(e as Error).message}`);
return { success: false, exitCode: 1, message: (e as Error).message };
}
}
// Codex initialization action
async function initCodexAction(
ctx: CommandContext,
@@ -941,6 +1043,16 @@ const initAction = async (ctx: CommandContext): Promise<CommandResult> => {
const full = ctx.flags.full as boolean;
const codexMode = ctx.flags.codex as boolean;
const dualMode = ctx.flags.dual as boolean;
const grokMode = ctx.flags.grok as boolean;
// Grok Build host (ADR-402) — separate surface under .grok/
if (grokMode) {
return initGrokAction(ctx, {
force,
statusLine: ctx.flags.grokStatusline === true || ctx.flags['grok-statusline'] === true,
dryRun: ctx.flags.dryRun === true || ctx.flags['dry-run'] === true,
});
}
if (codexMode && !dualMode) {
return initCodexAction(ctx, { codexMode, dualMode: false, force, minimal, full });
@@ -1767,6 +1879,24 @@ export const initCommand: Command = {
type: 'boolean',
default: false,
},
{
name: 'grok',
description: 'Initialize for Grok Build host (creates .grok/, team bus scripts, ADR-402 surface)',
type: 'boolean',
default: false,
},
{
name: 'grok-statusline',
description: 'With --grok: also merge the Ruflo status row into ~/.grok/config.toml (user file; off by default)',
type: 'boolean',
default: false,
},
{
name: 'dry-run',
description: 'With --grok: list the files init would write, write nothing',
type: 'boolean',
default: false,
},
{
name: 'dual',
description: 'Initialize for both Claude Code and OpenAI Codex',
@@ -1816,6 +1946,10 @@ export const initCommand: Command = {
{ command: 'claude-flow init upgrade --verbose', description: 'Show detailed upgrade info' },
{ command: 'claude-flow init --codex', description: 'Initialize for OpenAI Codex (AGENTS.md)' },
{ command: 'claude-flow init --codex --full', description: 'Codex init with all canonical packaged skills' },
{ command: 'claude-flow init --grok', description: 'Initialize for Grok Build (.grok/, team bus, ADR-402)' },
{ command: 'claude-flow init --grok --force', description: 'Overwrite existing Grok host files' },
{ command: 'claude-flow init --grok --dry-run', description: 'Show what init --grok would write' },
{ command: 'claude-flow init --grok --grok-statusline', description: 'Also add the Ruflo status row to ~/.grok/config.toml' },
{ command: 'claude-flow init --dual', description: 'Initialize for both Claude Code and Codex' },
{ command: 'claude-flow init --no-codex-detect', description: 'Skip auto-configuring OpenAI Codex even if it is installed' },
{ command: 'claude-flow init --no-skills-sh', description: 'Skip the post-init skills.sh registration' },
@@ -0,0 +1,455 @@
/**
* Grok Build host init (ADR-402).
*
* Mirrors `init --codex`: writes project-scoped `.grok/` surface + team bus scripts
* so any repo can run Ruflo Agent Teams under Grok without Claude SendMessage.
*
* Writes outside the project only with `statusLine: true` (`--grok-statusline`):
* the status row lives in the user's ~/.grok/config.toml, which is parsed and
* merged with a real TOML parser, never blindly appended to.
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import * as TOML from '@iarna/toml';
import { grokPackageRoot, grokTemplatesRoot } from './grok-templates.js';
export interface GrokInitOptions {
targetDir: string;
force?: boolean;
/** Also write docs/grok/README.md operator guide */
docs?: boolean;
/** Install the status row into the user's ~/.grok/config.toml (opt-in). */
statusLine?: boolean;
/** Report what would be written; write nothing. */
dryRun?: boolean;
/** Home directory for the user-level status row (tests). */
homeDir?: string;
}
export type GrokUserConfigAction =
| 'not-requested'
| 'created'
| 'merged'
| 'already-set'
| 'snippet'
| 'would-write';
export interface GrokUserConfigResult {
action: GrokUserConfigAction;
/** ~/.grok/config.toml */
path: string;
/** TOML to paste when init did not write it */
snippet: string;
reason?: string;
}
export interface GrokMcpLaunch {
command: string;
args: string[];
/** published: pinned to this CLI's npm version; checkout: this local build */
source: 'published' | 'checkout';
version: string;
}
export interface GrokInitResult {
success: boolean;
dryRun: boolean;
/** Files written (or, in a dry run, that would be written) */
filesCreated: string[];
filesSkipped: string[];
errors: string[];
mcp: GrokMcpLaunch;
userConfig: GrokUserConfigResult;
}
export { grokTemplatesRoot };
function packageVersion(root: string): string {
try {
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')).version || '0.0.0';
} catch {
return '0.0.0';
}
}
/**
* How the project .grok/config.toml starts the Ruflo MCP server.
*
* The team_* tools ship in the same release as `init --grok`, so the server is
* pinned to the version that is running this init — never `@latest`, which can
* be an older release without them. Running from a source checkout (not under
* node_modules) points at that checkout's CLI, since its version may not be
* published yet.
*/
export function rufloMcpLaunch(root: string = grokPackageRoot()): GrokMcpLaunch {
const version = packageVersion(root);
const published = root.split(path.sep).includes('node_modules');
if (!published) {
return {
command: 'node',
args: [path.join(root, 'bin', 'cli.js'), 'mcp', 'start'],
source: 'checkout',
version,
};
}
const npxArgs = ['-y', `ruflo@${version}`, 'mcp', 'start'];
return process.platform === 'win32'
? { command: 'cmd', args: ['/c', 'npx', ...npxArgs], source: 'published', version }
: { command: 'npx', args: npxArgs, source: 'published', version };
}
/** A TOML basic string. JSON escaping is a valid subset (\\, \", \uXXXX). */
function tomlString(value: string): string {
return JSON.stringify(value);
}
/**
* Fill the template placeholders. Every inserted value is escaped as a TOML
* string, so a Windows path such as C:\Users\x stays valid TOML.
*/
export function materializeGrokConfig(template: string, launch: GrokMcpLaunch, home: string): string {
const homeInString = tomlString(home.replace(/[\\/]+$/, '')).slice(1, -1);
return template
.replaceAll('{{RUFLO_MCP_COMMAND}}', tomlString(launch.command))
.replaceAll('{{RUFLO_MCP_ARGS}}', `[${launch.args.map(tomlString).join(', ')}]`)
.replaceAll('{{HOME}}/', `${homeInString}/`);
}
// ---------------------------------------------------------------------------
// User-level status row (~/.grok/config.toml [ui.status_line])
// ---------------------------------------------------------------------------
/** Where the opt-in status row script is installed: a stable absolute path. */
export function grokStatusScriptPath(home: string): string {
return path.join(home, '.grok', 'ruflo', 'host-statusline.mjs');
}
/** `node "<abs path>"` — no shell builtins, and never a repo-relative script. */
export function grokStatusLineCommand(home: string): string {
return `node "${grokStatusScriptPath(home)}"`;
}
export function grokStatusLineSnippet(home: string): string {
return TOML.stringify({
ui: { status_line: { type: 'command', command: grokStatusLineCommand(home) } },
} as TOML.JsonMap);
}
type MergeOutcome =
| { kind: 'write'; text: string }
| { kind: 'already-set'; ours: boolean }
| { kind: 'unsafe'; reason: string };
function stableJson(value: unknown): string {
return JSON.stringify(value, (_k, v) =>
v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, (v as Record<string, unknown>)[k]]))
: v,
);
}
function statusLineOf(doc: TOML.JsonMap): unknown {
const ui = doc.ui;
return ui && typeof ui === 'object' && !Array.isArray(ui) ? (ui as TOML.JsonMap).status_line : undefined;
}
/**
* Add the status row to an existing user config without disturbing it.
*
* Any existing `ui.status_line` — header, `[ ui.status_line ]`,
* `[ui."status_line"]`, dotted keys, or an inline table — is detected by
* parsing, and left alone. Otherwise the block is appended and the result is
* re-parsed: it is written only if it parses, carries exactly our status row,
* and every other value is unchanged. Anything else (unparseable file, `ui`
* as an inline table, …) falls back to printing the snippet.
*/
export function mergeGrokStatusLine(existing: string, home: string): MergeOutcome {
let before: TOML.JsonMap;
try {
before = TOML.parse(existing);
} catch (e) {
return { kind: 'unsafe', reason: `could not parse it as TOML (${(e as Error).message.split('\n')[0]})` };
}
const current = statusLineOf(before);
if (current !== undefined) {
const command = current && typeof current === 'object' ? (current as TOML.JsonMap).command : undefined;
return { kind: 'already-set', ours: command === grokStatusLineCommand(home) };
}
const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
const text = `${existing}${sep}\n# Ruflo loaded-status row (added by ruflo init --grok --grok-statusline)\n${grokStatusLineSnippet(home)}`;
let after: TOML.JsonMap;
try {
after = TOML.parse(text);
} catch (e) {
return { kind: 'unsafe', reason: `appending [ui.status_line] would not parse (${(e as Error).message.split('\n')[0]})` };
}
const added = statusLineOf(after) as TOML.JsonMap | undefined;
if (!added || added.type !== 'command' || added.command !== grokStatusLineCommand(home)) {
return { kind: 'unsafe', reason: 'the merged file did not contain the expected status row' };
}
const ui = after.ui as TOML.JsonMap;
delete ui.status_line;
if (before.ui === undefined && Object.keys(ui).length === 0) delete after.ui;
if (stableJson(after) !== stableJson(before)) {
return { kind: 'unsafe', reason: 'the merge would change other settings' };
}
return { kind: 'write', text };
}
/**
* If `file` is a symlink, resolve it to what it actually points at. A rename
* onto a symlink path replaces the symlink itself with a plain file — in a
* dotfiles-managed setup where `~/.grok/config.toml` is a symlink, that
* silently destroys the symlink instead of updating the file it points to
* (ADR-402 round-2 review, minor item 1). A broken symlink (target missing)
* falls back to writing `file` itself, same as a plain non-existent path.
*/
function resolveWriteTarget(file: string): string {
try {
if (fs.lstatSync(file).isSymbolicLink()) {
return fs.realpathSync(file);
}
} catch {
/* missing, unreadable, or a broken symlink target: write `file` itself */
}
return file;
}
function writeFileAtomic(file: string, text: string, mode?: number): void {
const target = resolveWriteTarget(file);
fs.mkdirSync(path.dirname(target), { recursive: true });
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmp, text, { encoding: 'utf-8', mode: mode ?? 0o600 });
fs.renameSync(tmp, target);
}
function wireGrokUserStatusLine(
home: string,
dryRun: boolean,
created: string[],
errors: string[],
): GrokUserConfigResult {
const dest = path.join(home, '.grok', 'config.toml');
const snippet = grokStatusLineSnippet(home);
const script = grokStatusScriptPath(home);
const result = (action: GrokUserConfigAction, reason?: string): GrokUserConfigResult => ({
action,
path: dest,
snippet,
reason,
});
let existing: string | null = null;
try {
existing = fs.existsSync(dest) ? fs.readFileSync(dest, 'utf-8') : null;
} catch (e) {
return result('snippet', `could not read ${dest}: ${(e as Error).message}`);
}
const outcome: MergeOutcome = existing === null
? { kind: 'write', text: `# Ruflo loaded-status row (added by ruflo init --grok --grok-statusline)\n${snippet}` }
: mergeGrokStatusLine(existing, home);
if (outcome.kind === 'unsafe') return result('snippet', `${dest}: ${outcome.reason}`);
if (outcome.kind === 'already-set' && !outcome.ours) {
return result('already-set', `${dest} already has its own [ui.status_line]; left it and did not install the Ruflo script`);
}
// Ours, or about to be ours: install/refresh the script it runs.
const needsConfig = outcome.kind === 'write';
if (dryRun) {
created.push(script);
if (needsConfig) created.push(dest);
return result(needsConfig ? 'would-write' : 'already-set');
}
try {
const src = path.join(grokTemplatesRoot(), 'scripts', 'host-statusline.mjs');
writeFileAtomic(script, fs.readFileSync(src, 'utf-8'), 0o755);
created.push(script);
if (!needsConfig) return result('already-set');
writeFileAtomic(dest, outcome.text);
created.push(dest);
return result(existing === null ? 'created' : 'merged');
} catch (e) {
errors.push(`${dest}: ${(e as Error).message}`);
return result('snippet', (e as Error).message);
}
}
// ---------------------------------------------------------------------------
// Project files
// ---------------------------------------------------------------------------
interface CopyContext {
force: boolean;
dryRun: boolean;
created: string[];
skipped: string[];
errors: string[];
}
/** First `object.key` (or `[index]`) path to a BigInt value, or null. */
function findBigIntKeyPath(value: unknown, path: string[] = []): string | null {
if (typeof value === 'bigint') return path.length ? path.join('.') : '(root)';
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const found = findBigIntKeyPath(value[i], [...path, `[${i}]`]);
if (found) return found;
}
return null;
}
if (value && typeof value === 'object' && !(value instanceof Date)) {
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const found = findBigIntKeyPath(v, [...path, k]);
if (found) return found;
}
}
return null;
}
/**
* Whether an existing `~/.grok/config.toml` can be safely parsed and
* serialized before init touches anything. A TOML integer literal above
* 2^53 parses to a BigInt (@iarna/toml), and `JSON.stringify` — used by
* `mergeGrokStatusLine`'s stableJson comparison — throws "Do not know how
* to serialize a BigInt" on it. That merge used to run as the LAST step of
* `executeGrokInit`, so the crash left project files already written and
* nothing to show for the failure. Checked here, first, so init either
* fully succeeds or writes nothing at all (ADR-402 round-2 review, minor
* item 2). Returns a user-facing error message naming the offending key,
* or null if the file is fine (or absent, or already unparseable as
* TOML — mergeGrokStatusLine reports that case on its own, safely).
*/
function validateExistingUserConfig(dest: string): string | null {
let existing: string;
try {
if (!fs.existsSync(dest)) return null;
existing = fs.readFileSync(dest, 'utf-8');
} catch (e) {
return `could not read ${dest}: ${(e as Error).message}`;
}
let doc: TOML.JsonMap;
try {
doc = TOML.parse(existing);
} catch {
return null;
}
// An existing status row is left as-is, so nothing in this file is
// serialized. A BigInt elsewhere must not fail init in that case
// (ADR-402 round-3 review). The status step reports already-set.
if (statusLineOf(doc) !== undefined) return null;
try {
JSON.stringify(doc);
} catch (e) {
const offending = findBigIntKeyPath(doc);
const where = offending ? ` at "${offending}"` : '';
return `${dest} has a value ruflo cannot serialize${where} (${(e as Error).message}) — fix or remove it before running init --grok --grok-statusline again`;
}
return null;
}
function writeProjectFile(ctx: CopyContext, dest: string, write: () => void): void {
try {
if (fs.existsSync(dest) && !ctx.force) {
ctx.skipped.push(dest);
return;
}
if (!ctx.dryRun) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
write();
}
ctx.created.push(dest);
} catch (e) {
ctx.errors.push(`${dest}: ${(e as Error).message}`);
}
}
function copyFile(ctx: CopyContext, src: string, dest: string): void {
writeProjectFile(ctx, dest, () => {
fs.copyFileSync(src, dest);
fs.chmodSync(dest, fs.statSync(src).mode);
});
}
function walkCopy(ctx: CopyContext, srcDir: string, destDir: string): void {
if (!fs.existsSync(srcDir)) {
ctx.errors.push(`Template directory missing: ${srcDir}`);
return;
}
for (const ent of fs.readdirSync(srcDir, { withFileTypes: true })) {
const src = path.join(srcDir, ent.name);
const dest = path.join(destDir, ent.name);
if (ent.isDirectory()) walkCopy(ctx, src, dest);
else copyFile(ctx, src, dest);
}
}
/**
* Initialize Grok host surface in targetDir.
*/
export function executeGrokInit(options: GrokInitOptions): GrokInitResult {
const { targetDir, force = false, docs = true, statusLine = false, dryRun = false } = options;
const home = options.homeDir || os.homedir();
const ctx: CopyContext = { force, dryRun, created: [], skipped: [], errors: [] };
const tpl = grokTemplatesRoot();
const mcp = rufloMcpLaunch();
const userConfig: GrokUserConfigResult = {
action: 'not-requested',
path: path.join(home, '.grok', 'config.toml'),
snippet: grokStatusLineSnippet(home),
};
const done = (): GrokInitResult => ({
success: ctx.errors.length === 0,
dryRun,
filesCreated: ctx.created,
filesSkipped: ctx.skipped,
errors: ctx.errors,
mcp,
userConfig,
});
if (!fs.existsSync(path.join(tpl, 'config.toml'))) {
ctx.errors.push(
`Grok templates not found at ${tpl}. Reinstall @claude-flow/cli or run from a monorepo checkout that includes templates/grok.`,
);
return done();
}
// Validate any existing user-level config before writing a single project
// file — see validateExistingUserConfig for why (BigInt/serialization
// failures must not leave a half-initialized project).
if (statusLine) {
const problem = validateExistingUserConfig(userConfig.path);
if (problem) {
ctx.errors.push(problem);
return done();
}
}
// .grok/config.toml — placeholders filled, then checked with a TOML parser
const config = materializeGrokConfig(fs.readFileSync(path.join(tpl, 'config.toml'), 'utf-8'), mcp, home);
try {
TOML.parse(config);
} catch (e) {
ctx.errors.push(`.grok/config.toml would not be valid TOML: ${(e as Error).message.split('\n')[0]}`);
return done();
}
writeProjectFile(ctx, path.join(targetDir, '.grok', 'config.toml'), () =>
fs.writeFileSync(path.join(targetDir, '.grok', 'config.toml'), config, 'utf-8'),
);
for (const dir of ['rules', 'agents', 'hooks', 'skills']) {
walkCopy(ctx, path.join(tpl, dir), path.join(targetDir, '.grok', dir));
}
// scripts/ team bus (project-local CLI + SubagentStop adapter + status row)
walkCopy(ctx, path.join(tpl, 'scripts'), path.join(targetDir, 'scripts'));
if (docs) {
walkCopy(ctx, path.join(tpl, 'docs'), path.join(targetDir, 'docs', 'grok'));
}
if (statusLine) {
Object.assign(userConfig, wireGrokUserStatusLine(home, dryRun, ctx.created, ctx.errors));
}
return done();
}
@@ -0,0 +1,30 @@
/**
* Where the Grok host templates live (ADR-402).
*
* Kept apart from grok-generator.ts so the team_* MCP tools can find the team
* bus scripts without loading the TOML parser on every MCP client import.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
/** Root of the installed @claude-flow/cli package (the one holding templates/grok). */
export function grokPackageRoot(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.resolve(here, '../../..'), // dist/src/init → package root
path.resolve(here, '../..'), // src/init → package root
];
for (const c of candidates) {
if (fs.existsSync(path.join(c, 'templates', 'grok', 'config.toml'))) {
return c;
}
}
return candidates[0];
}
/** templates/grok inside the installed @claude-flow/cli package. */
export function grokTemplatesRoot(): string {
return path.join(grokPackageRoot(), 'templates', 'grok');
}
+4
View File
@@ -61,3 +61,7 @@ export {
// Main executor
export { executeInit, executeUpgrade, executeUpgradeWithMissing, default } from './executor.js';
export type { UpgradeResult } from './executor.js';
// Grok Build host (ADR-402)
export { executeGrokInit } from './grok-generator.js';
export type { GrokInitOptions, GrokInitResult } from './grok-generator.js';
+4
View File
@@ -79,6 +79,8 @@ import { businessPodTools } from './mcp-tools/business-pod-tools.js';
// + auth headers; opt-in via CLAUDE_FLOW_HTTP_FETCH_ALLOW_PRIVATE / _AUTH=1.
import { httpFetchTools } from './mcp-tools/http-fetch-tools.js';
import { missionTools } from './mcp-tools/mission-tools.js';
// ADR-402 — host-agnostic Agent Teams bus (team_create/send/inbox/spawn/…)
import { teamTools } from './mcp-tools/team-tools.js';
// #1916: coverage-aware routing tools — defined in ruvector/coverage-tools.ts
// but were never registered, so the `ruflo hooks coverage-*` CLI subcommands
// failed with `Tool not found: hooks_coverage-route`.
@@ -194,6 +196,8 @@ registerTools([
...httpFetchTools,
// ADR-406 — mission semantic operations (create/plan/get/events/request_action)
...missionTools,
// ADR-402 — host-agnostic Agent Teams (9 tools)
...teamTools,
]);
// The capability brain consumes the completed live registry. This is injected
@@ -403,9 +403,10 @@ export const CAPABILITY_DOMAINS: readonly CapabilityDomainDefinition[] = [
{
id: 'swarm-coordination',
name: 'Swarm & Coordination',
prefixes: ['swarm_', 'coordination_', 'daa_'],
// team_: host-agnostic Agent Teams bus (ADR-402)
prefixes: ['swarm_', 'coordination_', 'daa_', 'team_'],
description: 'Topology-aware multi-agent coordination, task ownership, and collective scheduling.',
taskSignals: ['swarm', 'concurrent', 'parallel', 'coordinate', 'multi-agent'],
taskSignals: ['swarm', 'concurrent', 'parallel', 'coordinate', 'multi-agent', 'agent team'],
commands: ['swarm init', 'swarm status', 'swarm coordinate'],
skills: ['swarm-orchestration', 'swarm-advanced'],
agents: ['coordinator', 'coder', 'tester', 'reviewer'],
@@ -680,6 +681,7 @@ const TOOL_OWNERSHIP: ReadonlyArray<{
{ prefixes: ['guidance_'], packageOwner: '@claude-flow/guidance', pluginOwner: 'ruflo-core' },
{ prefixes: ['seraphina_'], packageOwner: '@claude-flow/cli', pluginOwner: 'ruflo-core' },
{ prefixes: ['x_federation_'], packageOwner: '@claude-flow/cli', pluginOwner: 'ruflo-x-gateway' },
{ prefixes: ['team_'], packageOwner: '@claude-flow/cli' },
] as const;
function ownershipForTool(
@@ -70,3 +70,5 @@ export { businessPodTools } from './business-pod-tools.js';
export { httpFetchTools } from './http-fetch-tools.js';
// ADR-406 — mission semantic operations
export { missionTools, createMissionTools } from './mission-tools.js';
// ADR-402 — host-agnostic Agent Teams bus
export { teamTools } from './team-tools.js';
@@ -0,0 +1,274 @@
/**
* Host-agnostic Agent Teams MCP tools (ADR-402).
*
* Comms live in Ruflo (filesystem under .claude-flow/teams/), not host
* SendMessage. team_spawn returns a host spawn plan (Grok spawn_subagent /
* Claude Task adapter).
*
* The store itself is templates/grok/scripts/grok-team-bus.mjs — the same file
* `init --grok` copies into a project as a CLI — so the MCP tools, the CLI and
* the SubagentStop hook share one implementation (locking, per-team mailboxes,
* atomic writes). This module validates MCP input and maps errors to results.
*/
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { type MCPTool, getProjectCwd } from './types.js';
import { validateText } from './validate-input.js';
import { grokTemplatesRoot } from '../init/grok-templates.js';
type BusResult = Record<string, unknown>;
type BusOp = (projectRoot: string, opts: Record<string, unknown>) => BusResult | Promise<BusResult>;
interface TeamBus {
createTeam: BusOp;
spawnMember: BusOp;
sendMessage: BusOp;
readInbox: BusOp;
teamStatus: BusOp;
setPlan: BusOp;
onStop: BusOp;
shutdownTeam: BusOp;
}
let busPromise: Promise<TeamBus> | null = null;
function loadBus(): Promise<TeamBus> {
if (!busPromise) {
const file = join(grokTemplatesRoot(), 'scripts', 'grok-team-bus.mjs');
busPromise = import(pathToFileURL(file).href) as Promise<TeamBus>;
busPromise.catch(() => {
busPromise = null;
});
}
return busPromise;
}
async function run(
op: keyof TeamBus,
opts: Record<string, unknown>,
): Promise<{ success: boolean } & Record<string, unknown>> {
try {
const bus = await loadBus();
// Some ops (create/spawn/send) hold the team lock, which now waits via a
// non-blocking async poll rather than Atomics.wait (ADR-402 round-2
// review, N3) — always await, whether or not this particular op returns
// a promise.
return { success: true, ...(await bus[op](getProjectCwd(), opts)) };
} catch (e) {
return { success: false, error: (e as Error).message || String(e) };
}
}
function textError(value: unknown, field: string, max: number): string | null {
if (value === undefined || value === null) return null;
const v = validateText(String(value), field, max);
return v.valid ? null : v.error || `Invalid ${field}`;
}
const teamProp = { type: 'string', description: 'Team id (alphanumeric, dash, underscore)' };
export const teamTools: MCPTool[] = [
{
name: 'team_create',
description:
'Create a host-agnostic Agent Team (ADR-402). State under .claude-flow/teams/<team>/. Use when native SendMessage/Task teammate bus is wrong or unavailable (Grok, Codex, multi-host). Pair with team_spawn for spawn plans and team_send/team_inbox for handoffs.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
name: teamProp,
topology: {
type: 'string',
description: 'Team topology (hierarchical, mesh, star, ring)',
},
maxAgents: { type: 'number', description: 'Max members (1-50, default 8); team_spawn refuses beyond it' },
host: { type: 'string', description: 'Host label (grok, claude, codex)' },
force: { type: 'boolean', description: 'Overwrite existing team metadata (reopens a shut-down team)' },
},
required: ['name'],
},
handler: async (input) =>
run('createTeam', {
name: input.name,
topology: input.topology,
maxAgents: input.maxAgents,
host: input.host,
force: input.force === true,
}),
},
{
name: 'team_spawn',
description:
'Register a teammate and return a host spawn plan (Grok spawn_subagent / Claude Task adapter) — does not execute the agent. Use when native Task has no way to register a teammate into the host-agnostic team roster (ADR-402); the host lead still spawns using the returned plan. Refused once the team is shut down or has maxAgents members. Pair with team_create first.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
team: teamProp,
agent: { type: 'string', description: 'Agent name' },
role: { type: 'string', description: 'Role (architect, developer, tester, reviewer, …)' },
prompt: { type: 'string', description: 'Task body embedded in spawn plan prompt' },
next: {
type: 'array',
description: 'Next agent name(s) for handoff',
items: { type: 'string' },
},
},
required: ['team', 'agent'],
},
handler: async (input) => {
const err = textError(input.prompt, 'prompt', 100_000);
if (err) return { success: false, error: err };
return run('spawnMember', {
team: input.team,
agent: input.agent,
role: input.role,
prompt: input.prompt,
next: input.next,
});
},
},
{
name: 'team_send',
description:
'Enqueue a message to a named agent mailbox on one team (or broadcast to every member with to="*"). Use when native SendMessage is wrong because the recipient may be on a different host (Grok, Codex) with no SendMessage equivalent — the message persists under .claude-flow/teams/<team>/mailbox/ instead of an in-memory channel. Pair with team_inbox to read it. Refused once the team is shut down.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
team: teamProp,
to: { type: 'string', description: 'Recipient agent name, or * to broadcast to current members' },
message: { type: 'string', description: 'Message body' },
summary: { type: 'string', description: 'Short summary' },
from: { type: 'string', description: 'Sender name (default: lead)' },
type: { type: 'string', description: 'Message type (handoff, status, …)' },
priority: { type: 'number', description: 'Integer 0-999, lower is read first (default 2)' },
},
required: ['team', 'to', 'message'],
},
handler: async (input) => {
const err =
textError(input.message ?? input.content, 'message', 500_000) ||
textError(input.summary, 'summary', 1_000) ||
textError(input.type, 'type', 64);
if (err) return { success: false, error: err };
return run('sendMessage', {
team: input.team,
to: input.to,
message: input.message ?? input.content,
summary: input.summary,
from: input.from,
type: input.type,
priority: input.priority,
});
},
},
{
name: 'team_inbox',
description:
'Drain (default) or peek one agent\'s mailbox on one team. Drained messages move to mailbox/<agent>/archive; concurrent drains never return the same message twice. Use when native SendMessage is wrong because there is no host-agnostic inbox to read from — this is the Grok/Codex-side counterpart to team_send for hosts without a live message channel.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
team: teamProp,
agent: { type: 'string', description: 'Agent name whose inbox to read' },
peek: { type: 'boolean', description: 'If true, do not archive/drain messages' },
},
required: ['team', 'agent'],
},
handler: async (input) =>
run('readInbox', { team: input.team, agent: input.agent, peek: input.peek === true }),
},
{
name: 'team_broadcast',
description:
'Fan-out a message to all registered members of one team (alias of team_send with to="*"; refused while the team has no members). Use when native SendMessage is wrong because it can\'t reach every teammate on a non-Claude host in one call; each recipient reads back via team_inbox on its own host.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
team: teamProp,
message: { type: 'string', description: 'Message body' },
summary: { type: 'string', description: 'Short summary' },
from: { type: 'string', description: 'Sender (default: lead)' },
},
required: ['team', 'message'],
},
handler: async (input) => {
const send = teamTools.find((t) => t.name === 'team_send');
if (!send) return { success: false, error: 'team_send not registered' };
return send.handler({
team: input.team,
to: '*',
message: input.message,
summary: input.summary,
from: input.from,
type: 'broadcast',
});
},
},
{
name: 'team_plan',
description:
'Set pipeline steps for a team (ordered agents); the first step becomes ready. Use when native TodoWrite is wrong because it can\'t drive multi-host agent sequencing — this pipeline advances via team_on_stop instead of a single host\'s task list.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
team: teamProp,
steps: {
type: 'array',
description: 'Ordered agent names or {id,agent} objects',
items: {},
},
},
required: ['team', 'steps'],
},
handler: async (input) => run('setPlan', { team: input.team, steps: input.steps }),
},
{
name: 'team_status',
description:
'Team members, plan progress, and pending mailbox counts for this team only. Use when native Task is wrong because it has no cross-host visibility into a team\'s roster or pipeline state — reads the same .claude-flow/teams/ state that team_plan and team_send write.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
team: teamProp,
},
required: ['team'],
},
handler: async (input) => run('teamStatus', { team: input.team }),
},
{
name: 'team_on_stop',
description:
'Mark agent idle, advance the team_plan pipeline, and return the next assignment hint. Use when native Task has no cross-host equivalent to SubagentStop-driven pipeline advancement; wire this from SubagentStop / post-task hooks instead of polling team_status.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
team: teamProp,
agent: { type: 'string', description: 'Agent that stopped' },
},
required: ['team', 'agent'],
},
handler: async (input) => run('onStop', { team: input.team, agent: input.agent }),
},
{
name: 'team_shutdown',
description:
'Close a team: marks it and its members shutdown, after which team_spawn, team_send, team_plan and team_on_stop refuse (team_inbox and team_status still work, to drain remaining mail). Use when native Task is wrong because it has no host-agnostic team lifecycle to close out.',
category: 'team',
inputSchema: {
type: 'object',
properties: {
team: teamProp,
},
required: ['team'],
},
handler: async (input) => run('shutdownTeam', { team: input.team }),
},
];
@@ -0,0 +1,35 @@
---
name: ruflo-architect
description: >
Ruflo swarm architect for Grok. Designs implementation blueprints, ADRs, and
file-level plans. Use for multi-file features before coding. Read-only by default;
hand off to ruflo-coder via team bus.
prompt_mode: full
permission_mode: plan
agents_md: true
---
You are the **architect** on a Ruflo Agent Team running under **Grok Build**.
This file is a session profile (`grok --agent-profile ruflo-architect` or `/agents`). On Grok Build 1.0.41, `spawn_subagent` does not select it. A spawned child is general-purpose, follows the spawn prompt, and must not call `spawn_subagent`.
## Comms protocol (mandatory)
- There is **no** Claude `SendMessage` tool.
- At start, drain your inbox:
`node scripts/grok-team-bus.mjs inbox --team <TEAM> --agent architect`
- When design is ready, hand off:
`node scripts/grok-team-bus.mjs send --team <TEAM> --to developer --from architect --summary "design-ready" --message "<paths + decisions>"`
- Prefer storing durable design under memory namespace `team:<TEAM>` when Ruflo MCP is available.
## Deliverable
Produce a decisive blueprint:
1. Patterns found (file:line)
2. Chosen architecture + trade-offs
3. Files to create/modify
4. Build sequence for developer → tester → reviewer
5. Risks / security notes
Do **not** implement code. Do not wait for Ruflo to execute — you design; the lead/developer executes.
@@ -0,0 +1,34 @@
---
name: ruflo-coder
description: >
Ruflo implementation agent for Grok. Writes production code from architect designs.
Prefer isolation=worktree when spawned. Handoffs via team bus, not SendMessage.
prompt_mode: full
agents_md: true
---
You are the **developer/coder** on a Ruflo Agent Team under **Grok Build**.
This file is a session profile (`grok --agent-profile ruflo-coder` or `/agents`). On Grok Build 1.0.41, `spawn_subagent` does not select it. A spawned child is general-purpose, follows the spawn prompt (worktree isolation when the plan says so), and must not call `spawn_subagent`.
## Comms protocol (mandatory)
- No Claude `SendMessage`.
- Start: `node scripts/grok-team-bus.mjs inbox --team <TEAM> --agent developer`
- If architect design is in the inbox or memory `team:<TEAM>`, follow it.
- When implementation is ready:
`node scripts/grok-team-bus.mjs send --team <TEAM> --to tester --from developer --summary "impl-ready" --message "<files changed + how to test>"`
- On stop: `node scripts/grok-team-bus.mjs on-stop --team <TEAM> --agent developer`
## Rules
- Prefer editing existing files; keep changes focused.
- Match project conventions (Claude.md / Agents.md / ADRs).
- Run available unit tests for touched areas when practical.
- If using a git worktree, leave a clear summary of the worktree path and branch for the lead to merge.
## Anti-patterns
- Waiting after `swarm_init` for code to appear by magic
- Inventing Claude Task / SendMessage APIs
- Parallel uncoordinated edits outside your worktree
@@ -0,0 +1,30 @@
---
name: ruflo-reviewer
description: >
Ruflo code review agent for Grok. Reviews quality, security, and test evidence.
Read-oriented; reports findings to the team lead via team bus.
prompt_mode: full
permission_mode: plan
agents_md: true
---
You are the **reviewer** on a Ruflo Agent Team under **Grok Build**.
This file is a session profile (`grok --agent-profile ruflo-reviewer` or `/agents`). On Grok Build 1.0.41, `spawn_subagent` does not select it. A spawned child is general-purpose, follows the spawn prompt, and must not call `spawn_subagent`.
## Comms protocol
- Inbox: `node scripts/grok-team-bus.mjs inbox --team <TEAM> --agent reviewer`
- Final report to lead:
`node scripts/grok-team-bus.mjs send --team <TEAM> --to lead --from reviewer --summary "review-complete" --message "<findings>"`
(If `lead` mailbox is unused, print the full review as your final response for the parent session.)
- `node scripts/grok-team-bus.mjs on-stop --team <TEAM> --agent reviewer`
## Review focus
1. Correctness vs design handoff
2. Security (injection, secrets, path traversal)
3. Test evidence quality
4. Maintainability / ADR alignment
Severity: blocker / major / minor / nit. Blockers must be explicit.
@@ -0,0 +1,26 @@
---
name: ruflo-tester
description: >
Ruflo tester for Grok. Writes and runs tests from implementation handoffs.
Use worktree isolation when modifying the tree. Reports to reviewer via team bus.
prompt_mode: full
agents_md: true
---
You are the **tester** on a Ruflo Agent Team under **Grok Build**.
This file is a session profile (`grok --agent-profile ruflo-tester` or `/agents`). On Grok Build 1.0.41, `spawn_subagent` does not select it. A spawned child is general-purpose, follows the spawn prompt, and must not call `spawn_subagent`.
## Comms protocol
- Inbox: `node scripts/grok-team-bus.mjs inbox --team <TEAM> --agent tester`
- After tests:
`node scripts/grok-team-bus.mjs send --team <TEAM> --to reviewer --from tester --summary "tests-ready" --message "<commands, pass/fail, coverage gaps>"`
- `node scripts/grok-team-bus.mjs on-stop --team <TEAM> --agent tester`
## Deliverable
- Test plan + implemented tests
- Exact commands to run
- Failures with file:line
- Gaps you did not cover
@@ -0,0 +1,63 @@
# Ruflo on Grok Build — project-scoped host config
# Loaded by Grok when this folder is trusted (see ~/.grok/trusted_folders.toml).
#
# Goal: full harness (MCP + hooks + skills + agent teams path), not a thin CLI wrapper.
# See: .grok/rules/ruflo-grok.md and v3/docs/adr/ADR-402-host-agnostic-agent-teams.md
#
# After editing: restart Grok (or reload MCPs) so tool lists refresh.
# ---------------------------------------------------------------------------
# Ruflo MCP — orchestrator / ledger (memory, swarm, hooks, team bus, neural)
# `ruflo init --grok` pins this to the Ruflo version that ran it, because the
# team_* tools ship in that same release (an older @latest may lack them).
# From a source checkout it points at that checkout's bin/cli.js instead.
# Cold npx can exceed 30s; keep timeouts high.
# ---------------------------------------------------------------------------
[mcp_servers.ruflo]
command = {{RUFLO_MCP_COMMAND}}
args = {{RUFLO_MCP_ARGS}}
enabled = true
startup_timeout_sec = 120
tool_timeout_sec = 180
# ---------------------------------------------------------------------------
# Optional: RuvNet Brain grounding (search_ruvnet over embedded rUv source)
# Install: npx ruvnet-brain@latest → kb under ~/.cache/ruvnet-brain/kb
#
# forge-mcp-all defaults KB_DIR to process.cwd(). When Grok spawns MCP from the
# project root, search fails with "passages sidecar not found: ./agentdb…".
# Always set env.KB_DIR to the absolute kb path. tool_timeout_sec high for cold
# BGE model download (first big search can take minutes).
# init --grok fills in your home directory for the {{HOME}} paths below.
# ---------------------------------------------------------------------------
# [mcp_servers.ruvnet-brain]
# command = "node"
# args = ["{{HOME}}/.cache/ruvnet-brain/kb/forge-mcp-all.mjs"]
# env = { KB_DIR = "{{HOME}}/.cache/ruvnet-brain/kb" }
# enabled = true
# startup_timeout_sec = 90
# tool_timeout_sec = 600
# ---------------------------------------------------------------------------
# Permissions — allow Ruflo CLI + helpers without re-prompting every turn
# (deny still wins over allow; PreToolUse hooks can still block)
# ---------------------------------------------------------------------------
[permission]
allow = [
"Bash(npx ruflo*)",
"Bash(npx @claude-flow*)",
"Bash(npx claude-flow*)",
"Bash(node .claude/*)",
"Bash(npx -y ruflo*)",
"Bash(node scripts/grok-team-bus.mjs*)",
"MCPTool(ruflo__*)",
"MCPTool(ruvnet-brain__*)",
]
# ---------------------------------------------------------------------------
# Subagent defaults are user-level on Grok Build 1.0.41.
# Project .grok/config.toml contributes only [mcp_servers], [plugins],
# [permission], and [mcp].max_output_bytes. A [subagents] table here is ignored.
# Put role defaults in ~/.grok/config.toml if you need them.
# Team children: pass host.grok.spawn from team_spawn (see docs/grok/README.md).
# ---------------------------------------------------------------------------
@@ -0,0 +1,186 @@
# Ruflo on Grok Build
How to run Ruflo inside [Grok Build](https://x.ai/) after `npx ruflo init --grok`.
**Host contract checked against Grok Build 1.0.41** (`grok 1.0.41`, macOS, 2026-09-27) and the user guide shipped with that binary (`~/.grok/docs/user-guide/`). Newer Grok builds change spawn arguments and which keys a project config may set. Run the check in [When Grok updates](#when-grok-updates) before treating this page as current.
Ruflo is the ledger: memory, swarm records, the team bus, hooks. Grok is the executor: it edits files, runs the shell, and calls `spawn_subagent`. After `team_create` or `swarm_init`, keep working. Those calls record coordination. They do not write the feature.
## What `init --grok` writes
| Path | Purpose |
|------|---------|
| `.grok/config.toml` | Project MCP server `ruflo` and permission allows |
| `.grok/rules/ruflo-grok.md` | Host doctrine loaded as a project rule |
| `.grok/agents/` | Session profiles (`ruflo-architect`, `ruflo-coder`, `ruflo-tester`, `ruflo-reviewer`) |
| `.grok/skills/agent-teams-grok/` | Skill the lead follows to spawn a pipeline |
| `.grok/hooks/subagent-stop-team.json` | `SubagentStop` → team bus idle-assign |
| `scripts/grok-team-bus.mjs`, `scripts/grok-team-store.mjs` | Team bus CLI (same store as the MCP `team_*` tools) |
| `scripts/grok-subagent-stop-hook.mjs` | Hook script: advances the plan when a teammate stops |
| `scripts/host-statusline.mjs` | The "what is loaded" line (see Loaded status) |
| `docs/grok/README.md` | This guide |
## Setup
From the app you want Grok to work in (any repo, not only the Ruflo monorepo):
```bash
npx -y ruflo@latest init --grok
# see what it would write, write nothing:
# npx -y ruflo@latest init --grok --dry-run
# overwrite an earlier scaffold:
# npx -y ruflo@latest init --grok --force
```
`init --grok` writes only inside this project, and prints every path it wrote. It pins `[mcp_servers.ruflo]` to the Ruflo version that ran it (for example `ruflo@3.48.0`), because the `team_*` tools ship in that same release and an older `@latest` from a stale npx cache would lack them. Run from a Ruflo source checkout, it points the server at that checkout's `bin/cli.js` instead; re-run it from a published release before you commit `.grok/config.toml`.
Then:
1. Trust the folder. `/hooks-trust`, or launch with `grok --trust`. On 1.0.41 one grant covers project MCP, hooks, skills, and project rules together. The decision is stored in `~/.grok/trusted_folders.toml`.
2. Restart Grok with this folder as the workspace so `.grok/config.toml` loads.
3. Confirm the server:
```bash
grok mcp list # ruflo (project)
grok mcp doctor ruflo # handshake ok; team_create present once this build is the server
```
`init` writes the MCP block itself. To register by hand, scope it to the project. The default scope is your user config (`~/.grok/config.toml`), which would install Ruflo for every repo:
```bash
grok mcp add ruflo --scope project -- npx -y ruflo@<version> mcp start
```
Use a version that ships `team_*` (the one `init --grok` pinned). `-s project` is the same flag. Cold `npx` often exceeds the 30s default startup timeout; the scaffold sets `startup_timeout_sec = 120`.
## Use it in a session
Grok does not put each Ruflo tool on the model as its own function. The model has `search_tool` and `use_tool`.
1. `search_tool` with a keyword (`team_create`, `memory_search`, `swarm_init`).
2. `use_tool` with the catalog key: `ruflo__team_create`, `ruflo__memory_search`.
3. Stay on a small subset. A full Ruflo server exposes hundreds of tools; keyword search is how the model reaches them.
MCP parameter names on the team tools are `team` and `agent`.
### Agent teams
```
team_create → team_plan → team_spawn → spawn_subagent → team_send / team_inbox → team_on_stop → team_shutdown
```
`team_spawn` returns a plan. The lead calls `spawn_subagent` with the top-level `prompt` and **only** `host.grok.spawn`:
```text
spawn_subagent({
prompt: spawnPlan.prompt,
description: spawnPlan.host.grok.spawn.description,
background: spawnPlan.host.grok.spawn.background,
isolation: spawnPlan.host.grok.spawn.isolation
})
```
`host.grok.advisory.capability_mode` and `subagent_type` describe the role. They are not arguments of `spawn_subagent` on 1.0.41. Passing them fails the call. The child is `general-purpose`. The prompt states the read-only constraint. `isolation: "worktree"` is the setting Grok enforces, and it is how writers avoid editing the lead's tree.
Only the lead spawns. A child that calls `spawn_subagent` hits the depth limit (1).
Pass `description` through unchanged. It is `<role>:<agent>@<team>`, and the `SubagentStop` hook parses it to mark that agent idle and advance that team's plan. If a description has no `@team`, the hook uses the one active team that lists the agent; when several do, it advances nothing and says so on stderr.
How the bus behaves:
- State and mail live in `.claude-flow/teams/<team>/` (`team.json`, `mailbox/<agent>/`). Mail is per team: `team_inbox` needs `team` and `agent`, and `team_status` counts only that team's mail.
- `team.json` changes take a lock and are written to a temp file and renamed, so parallel spawns and hooks do not drop updates.
- `team_spawn` refuses once the team has `maxAgents` members (re-spawning an existing member is fine).
- `team_shutdown` closes the team: spawn, send, broadcast, plan, and on-stop refuse afterwards. `team_inbox` and `team_status` still work so remaining mail can be drained. `team_create --force` reopens the name.
- A broadcast (`to: "*"`) goes to current members and is refused while the team has none.
- `priority` is an integer 0-999; lower is read first.
- Several teams can be active at once; every call names its team.
`.grok/agents/ruflo-coder.md` and the other profiles apply when you start Grok as that agent (`grok --agent-profile ruflo-coder` or `/agents`). Spawn does not select them.
CLI bus, same plan shape, no MCP required:
```bash
node scripts/grok-team-bus.mjs create --name feature-x --topology hierarchical
node scripts/grok-team-bus.mjs plan --team feature-x \
--steps '["architect","developer","tester","reviewer"]'
node scripts/grok-team-bus.mjs spawn --team feature-x --agent architect --role architect \
--prompt "Design the feature." --next developer
```
Map the printed `spawnPlan.host.grok.spawn` the same way. Skill: `agent-teams-grok`.
### What the project config loads
On 1.0.41, `.grok/config.toml` contributes:
- `[mcp_servers]`
- `[plugins]`
- `[permission]`
- `[mcp].max_output_bytes`
Other tables, including `[subagents]`, are read from `~/.grok/config.toml` only. The scaffold used to write `[subagents] enabled = true` into the project file. That key does nothing there. Subagents are on by default.
Permission allows in the scaffold use Grok's native forms, `Bash(...)` and `MCPTool(ruflo__*)`. Deny still beats allow.
### Hooks
`.grok/hooks/subagent-stop-team.json` runs on `SubagentStop` and calls `scripts/grok-subagent-stop-hook.mjs`. It runs only after the folder is trusted. `$CLAUDE_PROJECT_DIR` is set (alias of `GROK_WORKSPACE_ROOT`).
`UserPromptSubmit` hooks that allow the prompt do not inject stdout into the model on 1.0.41. If a route hook seems to vanish, read `.swarm/route-latest.md` or call the route tool. That is a host limit, not a missing Ruflo file.
## Loaded status
`scripts/host-statusline.mjs` is the shared "what loaded" line: `RuFlo loaded │ ruflo │ rules │ 4 agents │ skill │ hook │ trusted`, or the names of what is missing. Claude shows its own richer `statusLine`. Codex has no status row; `node scripts/host-statusline.mjs --host codex` prints the same facts.
Grok reads `[ui.status_line]` only from your user file, `~/.grok/config.toml`; a project config cannot set it. `init --grok` does not touch that file unless you ask. It prints the snippet to add, or with `--grok-statusline` it:
- copies the script to `~/.grok/ruflo/host-statusline.mjs`, and
- merges this into `~/.grok/config.toml` (parsed with a TOML parser; an existing `ui.status_line` in any form is left alone, and a file it cannot merge safely is not changed — you get the snippet instead):
```toml
[ui.status_line]
type = "command"
command = 'node "/home/you/.grok/ruflo/host-statusline.mjs"'
```
The command is an absolute path to your own copy, so no repo can substitute a script of its own. It reports on whichever folder Grok is in. Restart Grok after the user file changes.
## Prove it
From a Ruflo checkout (this script is not copied by `init --grok`; run `init --grok` in the checkout first):
```bash
node scripts/probe-host-live.mjs --host grok
node scripts/probe-host-live.mjs --host all --no-execute
node scripts/probe-host-live.mjs --host grok --live
```
One contract (discover, connect, SessionStart receipt, optional memory round trip) with a Grok, Claude, or Codex adapter. A check that CLI cannot answer is a skip, not a pass.
## When Grok updates
The July 2026 host work assumed Grok 1.0.34 and a spawn schema that accepted `subagent_type` and `capability_mode`. 1.0.41 dropped both from the model-facing tool and stopped applying `[subagents]` from project config. The same class of drift will happen again.
After `grok --version` changes:
1. Open `~/.grok/docs/user-guide/16-subagents.md` and read **Spawning Subagents**. The parameter table is the contract. Forward a key only if it is listed.
2. Open `26-config-reference.md` and confirm which sections a project `.grok/config.toml` may set (1.0.41: mcp servers, plugins, permission, mcp output cap).
3. Open `07-mcp-servers.md` and confirm `grok mcp add --scope` still defaults to `user`, and that tools are still `search_tool` / `use_tool` with `server__tool` names.
4. Open `10-hooks.md` and confirm `SubagentStop`, folder trust, and `$CLAUDE_PROJECT_DIR`.
5. Open `22-permissions-and-safety.md` and confirm `MCPTool(server__tool)` and `Bash(...)` still match the allows in `.grok/config.toml`.
6. Spawn once with `host.grok.spawn` only. If the tool rejects `background`, the user guide's name for that flag is `run_in_background` — use the name the live schema accepts.
7. Update the `contract` string in `team_spawn` / `scripts/grok-team-bus.mjs` and this page to the version you just checked.
`grok inspect` shows which rules, skills, and MCP servers the trusted folder actually loaded.
## Optional
```bash
npx -y ruflo@latest daemon start
npx -y ruflo@latest doctor
```
RuvNet Brain is off in the scaffold. Uncomment `[mcp_servers.ruvnet-brain]` in `.grok/config.toml` after `npx ruvnet-brain@latest`, and set `env.KB_DIR` to the absolute `~/.cache/ruvnet-brain/kb` path. `init --grok` fills in your home directory for those paths when it creates the file.
Architecture: ADR-402 (Host-Agnostic Agent Teams), in the Ruflo repo at `v3/docs/adr/ADR-402-host-agnostic-agent-teams.md`.
@@ -0,0 +1,15 @@
{
"hooks": {
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/grok-subagent-stop-hook.mjs\"",
"timeout": 5
}
]
}
]
}
}
@@ -0,0 +1,132 @@
# Ruflo on Grok — host doctrine
You are running **inside Grok Build**, not Claude Code. Ruflo is the harness (orchestrator/ledger). **You** execute.
## Division of labor
| Layer | Role |
|-------|------|
| **Grok** | Executor — edit files, shell, tests, `spawn_subagent`, synthesize |
| **Ruflo MCP/CLI** | Orchestrator — memory, swarm records, team bus, learning, security hooks |
After any `swarm_init` / `team_create` / `agent_spawn` / MCP coordination call: **immediately continue working**. Never wait for Ruflo to “build the feature.” It does not execute code.
## Tool map (Claude / CLAUDE.md → Grok)
| Claude / CLAUDE.md | Grok |
|--------------------|------|
| `Task(...)` / Agent Teams spawn | `spawn_subagent` (`background: true` for parallel) |
| `SendMessage` | Ruflo MCP `team_send` / `team_inbox` (host-agnostic bus) — until available: AgentDB memory namespace `team:{id}` or `collaboration` |
| `TodoWrite` | `todo_write` |
| `Bash` | `run_terminal_command` |
| `Read` / `Grep` / `Glob` | `read_file` / `grep` / `list_dir` |
| `Write` / `Edit` / `MultiEdit` | `write` / `search_replace` |
| `claude -p` workers | `spawn_subagent` or `grok -p` |
| `mcp__claude-flow__*` / `mcp__ruflo__*` | `search_tool` then `use_tool` with `ruflo__*` names |
| Haiku / Sonnet / Opus routing | Ignore Anthropic tiers; use Grok models / subagent `model` only when needed |
## MCP usage
1. Discover: `search_tool` with keywords (`memory_search`, `swarm_init`, `team_`, `hooks`).
2. Call: `use_tool` with the fully-qualified name (e.g. `ruflo__memory_search`).
3. Prefer memory + team/swarm subset over flooding with all 300+ tools.
## Before complex work (mandatory)
1. `memory_search` (namespace `patterns` when available) for similar past solutions.
2. If RuvNet Brain / `search_ruvnet` is available: ground stack choices in real rUv source **before** recommending Pinecone, pgvector, LangChain, or other training-prior defaults.
3. For multi-file / multi-role work: open a **named team** pipeline (see Agent Teams below).
## After success (mandatory)
Store what worked via `memory_store` (namespace `patterns`): approach, files, gotchas, commands.
## Agent Teams on Grok (comms-first)
Named agents coordinate via the **Ruflo team bus** (MCP), not proprietary Claude `SendMessage`.
### Preferred pipeline
```
lead (you)
→ spawn named subagents in ONE turn (background: true)
→ each agent handoff via team_send / memory key
→ SubagentStop / you assign next step
→ lead synthesizes
```
### Spawn contract (Grok Build 1.0.41)
`spawn_subagent` takes `prompt`, `description`, `background`, `isolation`, and optionally `cwd`, `resume_from`, and `model`. It does not take `subagent_type` or `capability_mode`. An omitted type is `general-purpose`. Nesting depth is 1, so only the lead spawns.
`team_spawn` returns `host.grok.spawn` for those arguments and `host.grok.advisory` for the role constraint. Pass `spawn` plus `prompt`. The prompt tells a read-only role not to edit. `isolation: "worktree"` is what Grok enforces for writers.
`.grok/agents/ruflo-*` are session profiles (`grok --agent-profile` or `/agents`). They are not spawn types.
Project `.grok/config.toml` contributes `[mcp_servers]`, `[plugins]`, `[permission]`, and `[mcp].max_output_bytes`. A `[subagents]` table in that file is ignored. Subagent defaults belong in `~/.grok/config.toml`.
Operator guide: `docs/grok/README.md`. If `grok --version` moves past 1.0.41, re-check `~/.grok/docs/user-guide/16-subagents.md` and `26-config-reference.md` before forwarding new spawn keys.
### Defaults
| Role | Prompt constraint | `isolation` |
|------|-------------------|-------------|
| researcher / architect / reviewer | read-only | `none` |
| coder / implementer | full tools | **`worktree`** |
| tester | full tools | **`worktree`** when writing tests |
- Always pass a clear **name/role** in the prompt (“You are architect on team feature-x”).
- Tell each agent **who to message next** and **what artifact to produce**.
- Prefer short cycles with verification gates over one giant agent.
- Hierarchical topology for coding swarms; max ~6–8 concurrent workers.
### Spawn pattern (all in one message)
```
spawn_subagent({ prompt, description, background: true, isolation: "none" })
architect → team_send to developer
spawn_subagent({ ..., isolation: "worktree" })
developer → tester → reviewer (reviewer isolation "none")
```
Use MCP team tools or memory handoffs. There is no `SendMessage` tool.
## Skills
- Prefer project skills under `.agents/skills/` and `.claude/skills/`.
- High-value first: swarm-orchestration, memory-management, sparc-methodology, security-audit, github-automation, hive-mind.
- Load a skill when the task matches its description; don’t dump all 100+ into context.
## Hooks (already in `.claude/settings.json`)
Grok loads Claude-compatible hooks when the folder is trusted. Expect:
- SessionStart: session restore + memory import
- UserPromptSubmit: route / intelligence context
- PreToolUse Bash: pre-bash safety
- PostToolUse edits: post-edit learning
- SubagentStop: post-task / idle assign
If route context does not appear in the transcript, call `hooks route` via CLI/MCP or read `.swarm/route-latest.md` when present.
## CLI fallback
When MCP is down:
```bash
npx -y ruflo@latest memory search --query "…"
npx -y ruflo@latest memory store --key "…" --value "…" --namespace patterns
npx -y ruflo@latest swarm init --topology hierarchical --max-agents 8
npx -y ruflo@latest doctor
```
Team calls have their own fallback that needs no npx: `node scripts/grok-team-bus.mjs <command> --team <team> …` (same store as the `team_*` tools).
## Anti-patterns
- Waiting after `swarm start` for code to appear
- Calling Claude-only tools (`SendMessage`, Claude `Task` agent types as host APIs)
- Shared-tree parallel coders without worktrees (file races)
- Skipping memory_search / grounding on stack decisions
- Creating docs in repo root or committing secrets
)
@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Grok SubagentStop → team bus on-stop (ADR-402).
*
* Reads the hook JSON from stdin (Grok / Claude compatible fields) and marks
* the stopped teammate idle, advancing its team's plan.
*
* Which teammate: team_spawn sets the spawn description to
* `<role>:<agent>@<team>`. The hook parses that; TEAM_NAME / SUBAGENT_NAME
* env vars or explicit `team` / `agentName` fields override it.
*
* Which team, when the description has no @team: the one active team that
* lists the agent as a member. Several matches are reported as ambiguous and
* nothing is advanced.
*
* Exit codes: 0 done, or the subagent is not a team member (nothing to do);
* 1 a team subagent could not be recorded (reason on stderr, non-blocking).
*/
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const bus = await import(pathToFileURL(join(here, 'grok-team-bus.mjs')).href);
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.env.GROK_WORKSPACE_ROOT || process.cwd();
function readStdin() {
if (process.stdin.isTTY) return Promise.resolve('');
return new Promise((resolve) => {
let data = '';
const t = setTimeout(() => resolve(data), 1000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', (c) => { data += c; });
process.stdin.on('end', () => { clearTimeout(t); resolve(data); });
process.stdin.on('error', () => { clearTimeout(t); resolve(data); });
});
}
function fail(reason) {
process.stderr.write(`ruflo team hook: ${reason}\n`);
process.exit(1);
}
let input = {};
const raw = await readStdin();
try {
input = raw.trim() ? JSON.parse(raw) : {};
} catch {
input = {};
}
const description = input.description ?? input.toolInput?.description ?? input.tool_input?.description;
const parsed = bus.parseSpawnDescription(description) || {};
const agent =
process.env.SUBAGENT_NAME
|| input.subagentName
|| input.agentName
|| input.agent
|| parsed.agent;
const explicitTeam = process.env.TEAM_NAME || input.teamName || input.team || parsed.team;
if (!agent) {
// Not a team spawn (no description in <role>:<agent>@<team> form).
process.exit(0);
}
let team = explicitTeam;
if (!team) {
let matches;
try {
matches = bus.teamsWithMember(projectRoot, String(agent));
} catch (err) {
fail(err.message);
}
if (matches.length === 0) process.exit(0);
if (matches.length > 1) {
fail(`agent "${agent}" is on several active teams (${matches.join(', ')}); spawn with the description from team_spawn (<role>:<agent>@<team>) or set TEAM_NAME`);
}
team = matches[0];
}
try {
const r = await bus.onStop(projectRoot, { team: String(team), agent: String(agent) });
if (!r.member && !r.advanced) {
fail(`agent "${agent}" is not a member of team "${team}" and is not its current plan step`);
}
} catch (err) {
fail(err.message || String(err));
}
process.exit(0);
@@ -0,0 +1,525 @@
#!/usr/bin/env node
/**
* Host-agnostic Agent Teams bus (ADR-402) — filesystem store.
*
* Works from any host (Grok, Claude, Codex) without proprietary SendMessage.
* The team_* MCP tools and the SubagentStop hook import this file, and
* `init --grok` copies it (with grok-team-store.mjs, the storage layer) into a
* project as a zero-dependency CLI:
*
* node scripts/grok-team-bus.mjs <create|spawn|send|inbox|status|plan|on-stop|shutdown> --team <team> …
*
* Run with no arguments for the full usage. `spawn` prints the plan that Grok
* maps to spawn_subagent.
*/
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
NAME_RE,
assertActive,
ensureRealDir,
loadTeam,
mailboxDir,
nowIso,
readJson,
realDirExists,
safeName,
teamDir,
teamFile,
teamsRoot,
updateTeam,
withTeamLock,
writeJsonAtomic,
} from './grok-team-store.mjs';
export const GROK_CONTRACT = 'grok-build-1.0.41';
export const MAX_PRIORITY = 999;
export const DEFAULT_PRIORITY = 2;
const ROLE_DEFAULTS = {
researcher: { capability_mode: 'read-only', isolation: 'none', subagent_type: 'explore', claudeTaskType: 'researcher' },
architect: { capability_mode: 'read-only', isolation: 'none', subagent_type: 'plan', claudeTaskType: 'system-architect' },
developer: { capability_mode: 'all', isolation: 'worktree', subagent_type: 'general-purpose', claudeTaskType: 'coder' },
coder: { capability_mode: 'all', isolation: 'worktree', subagent_type: 'general-purpose', claudeTaskType: 'coder' },
tester: { capability_mode: 'all', isolation: 'worktree', subagent_type: 'general-purpose', claudeTaskType: 'tester' },
reviewer: { capability_mode: 'read-only', isolation: 'none', subagent_type: 'general-purpose', claudeTaskType: 'reviewer' },
security: { capability_mode: 'read-only', isolation: 'none', subagent_type: 'general-purpose', claudeTaskType: 'security-auditor' },
coordinator: { capability_mode: 'all', isolation: 'none', subagent_type: 'general-purpose', claudeTaskType: 'coordinator' },
};
// ---------------------------------------------------------------------------
// Spawn plan
// ---------------------------------------------------------------------------
/**
* The spawn description carries role, agent, and team so the SubagentStop
* hook can resolve which plan step finished: `<role>:<agent>@<team>`.
*/
export function spawnDescription(role, agent, team) {
return `${role}:${agent}@${team}`;
}
/** Inverse of spawnDescription. Also accepts a bare agent name. */
export function parseSpawnDescription(desc) {
if (typeof desc !== 'string') return null;
const m = /^\s*(?:([A-Za-z0-9_-]+):)?([A-Za-z0-9][A-Za-z0-9_-]{0,63})(?:@([A-Za-z0-9][A-Za-z0-9_-]{0,63}))?\s*$/.exec(desc);
if (!m) return null;
return { role: m[1] || null, agent: m[2], team: m[3] || null };
}
function roleConstraint(mode, isolation) {
if (mode === 'read-only') {
return 'Constraint: do not create, edit, delete, or move files, and do not run commands that change the repo. Read, search, and report.';
}
if (isolation === 'worktree') {
return 'Constraint: Grok isolates your edits in a git worktree. Stay inside that worktree and report its path when you finish.';
}
return 'Constraint: report results to the team lead. Stay inside the files this task names.';
}
function buildSpawnPlan(t, agent, role, prompt, next) {
const defaults = ROLE_DEFAULTS[role] || ROLE_DEFAULTS.coder;
const spawn = {
description: spawnDescription(role, agent, t.name),
background: true,
isolation: defaults.isolation,
};
const protocol = [
`You are "${agent}" (role: ${role}) on team "${t.name}".`,
'Grok Build 1.0.41 runs you as a general-purpose subagent. Nesting depth is 1: do not call spawn_subagent.',
roleConstraint(defaults.capability_mode, defaults.isolation),
'Host-agnostic Agent Teams bus (ADR-402). There is no Claude SendMessage tool.',
`Read your inbox first: team_inbox (team=${t.name}, agent=${agent}).`,
'Hand off with team_send. Without the Ruflo MCP server, use the CLI:',
` node scripts/grok-team-bus.mjs inbox --team ${t.name} --agent ${agent}`,
` node scripts/grok-team-bus.mjs send --team ${t.name} --from ${agent} --to <next> --summary "<short>" --message "<handoff>"`,
next.length
? `Primary next agent(s): ${next.join(', ')}`
: 'Report completion to the team lead (parent session).',
'',
'Task:',
prompt || `(No task body — wait for inbox / lead instructions for role ${role}.)`,
].join('\n');
return {
teamId: t.id,
name: agent,
role,
prompt: protocol,
next,
host: {
grok: {
// Pass `spawn` to spawn_subagent together with top-level `prompt`.
contract: GROK_CONTRACT,
spawn,
advisory: {
capability_mode: defaults.capability_mode,
subagent_type: defaults.subagent_type,
note: 'Grok Build 1.0.41 does not take capability_mode or subagent_type on spawn_subagent. The child is general-purpose; the prompt carries the constraint. isolation is the enforced knob. .grok/agents/*.md are session profiles (grok --agent-profile), not spawn types.',
},
},
claude: {
taskType: defaults.claudeTaskType || role,
note: 'optional back-compat path via Task tool',
},
},
};
}
// ---------------------------------------------------------------------------
// Operations (throw Error on failure; return plain objects)
// ---------------------------------------------------------------------------
export async function createTeam(projectRoot, opts) {
const name = safeName(opts.name, 'name');
const dir = teamDir(projectRoot, name);
ensureRealDir(projectRoot, dir);
const maxAgents = Math.min(Math.max(Math.trunc(Number(opts.maxAgents)) || 8, 1), 50);
const t = {
id: name,
name,
topology: opts.topology || 'hierarchical',
maxAgents,
status: 'active',
createdAt: nowIso(),
host: opts.host || 'grok',
members: {},
plan: { steps: [], index: 0 },
};
return withTeamLock(projectRoot, name, () => {
const existed = fs.existsSync(teamFile(projectRoot, name));
if (existed && !opts.force) {
throw new Error(`Team "${name}" already exists (pass force to overwrite its metadata)`);
}
if (existed && opts.force) {
// Recreating an existing team must not leak the old incarnation's
// mailboxes (pending or archived messages) into the fresh one — wipe
// them along with the metadata (ADR-402 round-2 review, N4).
const mailbox = path.join(dir, 'mailbox');
if (realDirExists(mailbox)) fs.rmSync(mailbox, { recursive: true, force: true });
}
writeJsonAtomic(teamFile(projectRoot, name), t);
ensureRealDir(projectRoot, path.join(dir, 'mailbox'));
return { action: 'create', team: t };
});
}
function parseNext(next) {
const list = Array.isArray(next)
? next.map(String)
: typeof next === 'string' ? next.split(',') : [];
return list.map((s) => s.trim()).filter(Boolean).map((s) => safeName(s, 'next'));
}
export async function spawnMember(projectRoot, opts) {
const agent = safeName(opts.agent, 'agent');
const role = safeName(opts.role || agent, 'role');
const next = parseNext(opts.next);
const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
const result = await updateTeam(projectRoot, opts.team, (t) => {
assertActive(t);
const members = t.members || (t.members = {});
if (!members[agent] && Object.keys(members).length >= t.maxAgents) {
throw new Error(`Team "${t.name}" is full (maxAgents=${t.maxAgents})`);
}
const plan = buildSpawnPlan(t, agent, role, prompt, next);
members[agent] = {
name: agent,
role,
status: 'registered',
registeredAt: nowIso(),
next,
spawn: plan.host,
};
return { action: 'spawn', spawnPlan: plan, teamId: t.id };
});
ensureRealDir(projectRoot, mailboxDir(projectRoot, opts.team, agent));
return result;
}
/**
* Priority must be a plain base-10 integer literal 0-MAX_PRIORITY. A string
* value is checked against that shape directly, rejecting anything
* `Number()` would otherwise happily coerce — scientific notation ("1e2")
* and hex/octal/binary prefixes ("0x10", "0o20", "0b101") all parse to an
* in-range integer but are not the small plain integer this field is meant
* to be (ADR-402 round-2 review, N5, minor item 5).
*/
export function parsePriority(value) {
if (value === undefined || value === null || value === '') return DEFAULT_PRIORITY;
let n;
if (typeof value === 'number') {
n = value;
} else {
const s = String(value).trim();
if (!/^-?\d+$/.test(s)) {
throw new Error(`priority must be an integer 0-${MAX_PRIORITY} (lower is read first), got "${value}"`);
}
n = Number(s);
}
if (!Number.isInteger(n) || n < 0 || n > MAX_PRIORITY) {
throw new Error(`priority must be an integer 0-${MAX_PRIORITY} (lower is read first), got "${value}"`);
}
return n;
}
const messageFileName = (msg) => `${String(msg.priority).padStart(3, '0')}_${msg.id}.json`;
export function sendMessage(projectRoot, opts) {
const to = opts.to || '*';
if (to !== '*') safeName(to, 'to');
const from = safeName(opts.from || 'lead', 'from');
const content = String(opts.message ?? opts.content ?? '');
if (!content.trim()) throw new Error('message is required');
const priority = parsePriority(opts.priority);
// Hold the lock so a concurrent shutdown or spawn cannot interleave.
return withTeamLock(projectRoot, opts.team, () => {
const t = loadTeam(projectRoot, opts.team);
assertActive(t);
const recipients = to === '*' ? Object.keys(t.members || {}) : [to];
if (recipients.length === 0) {
throw new Error(`Team "${t.name}" has no members to broadcast to — team_spawn them first, or send to a named agent`);
}
const msg = {
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
teamId: t.id,
from,
to,
summary: String(opts.summary || ''),
content,
type: String(opts.type || 'handoff'),
priority,
timestamp: nowIso(),
};
for (const r of recipients) {
const dir = mailboxDir(projectRoot, t.name, r);
ensureRealDir(projectRoot, dir);
// wx: never write through a pre-existing file or symlink of that name.
fs.writeFileSync(path.join(dir, messageFileName(msg)), JSON.stringify(msg, null, 2) + '\n', {
encoding: 'utf8',
flag: 'wx',
mode: 0o600,
});
}
return { action: 'send', message: msg, recipients };
});
}
function pendingFiles(dir) {
if (!realDirExists(dir)) return [];
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
}
/**
* Drain (default) or peek one agent's mailbox on one team. A drain claims each
* file by renaming it into archive/ first, so two concurrent drains each get a
* disjoint set of messages and neither throws.
*/
export function readInbox(projectRoot, opts) {
const agent = safeName(opts.agent, 'agent');
loadTeam(projectRoot, opts.team);
const dir = mailboxDir(projectRoot, opts.team, agent);
const peek = opts.peek === true;
const messages = [];
const files = pendingFiles(dir);
if (files.length && !peek) ensureRealDir(projectRoot, path.join(dir, 'archive'));
for (const f of files) {
let file = path.join(dir, f);
if (!peek) {
const archived = path.join(dir, 'archive', f);
try {
fs.renameSync(file, archived);
} catch (err) {
if (err.code === 'ENOENT') continue; // another reader claimed it
throw err;
}
file = archived;
}
const msg = readJson(file);
if (msg) messages.push(msg);
}
return { action: 'inbox', team: opts.team, agent, messages, peek };
}
export function teamStatus(projectRoot, opts) {
const t = loadTeam(projectRoot, opts.team);
const names = new Set(Object.keys(t.members || {}));
const box = path.join(teamDir(projectRoot, opts.team), 'mailbox');
if (realDirExists(box)) {
for (const ent of fs.readdirSync(box, { withFileTypes: true })) {
if (ent.isDirectory() && NAME_RE.test(ent.name)) names.add(ent.name);
}
}
const pendingMail = {};
for (const name of [...names].sort()) {
pendingMail[name] = pendingFiles(mailboxDir(projectRoot, opts.team, name)).length;
}
return { action: 'status', team: t, pendingMail };
}
function normalizeSteps(steps) {
let list = steps;
if (typeof list === 'string') {
try {
list = JSON.parse(list);
} catch {
list = list.split(',').map((s) => s.trim()).filter(Boolean);
}
}
if (!Array.isArray(list) || list.length === 0) throw new Error('steps must be a non-empty list');
return list.map((s, i) => {
const agent = typeof s === 'string' ? s : s && (s.agent || s.id);
const id = typeof s === 'string' ? s : s && (s.id || s.agent);
return {
id: safeName(String(id), 'step id'),
agent: safeName(String(agent), 'step agent'),
status: i === 0 ? 'ready' : 'pending',
};
});
}
export function setPlan(projectRoot, opts) {
const steps = normalizeSteps(opts.steps);
return updateTeam(projectRoot, opts.team, (t) => {
assertActive(t);
t.plan = { steps, index: 0, updatedAt: nowIso() };
return { action: 'plan', plan: t.plan };
});
}
export function onStop(projectRoot, opts) {
const agent = safeName(opts.agent, 'agent');
return updateTeam(projectRoot, opts.team, (t) => {
assertActive(t);
const member = t.members?.[agent];
if (member) {
member.status = 'idle';
member.lastStopAt = nowIso();
}
const plan = t.plan || (t.plan = { steps: [], index: 0 });
const cur = plan.steps[plan.index];
const advanced = Boolean(cur && (cur.agent === agent || cur.id === agent));
if (advanced) {
cur.status = 'done';
plan.index = Math.min(plan.index + 1, plan.steps.length);
const nxt = plan.steps[plan.index];
if (nxt) nxt.status = 'ready';
plan.updatedAt = nowIso();
}
const nextStep = plan.steps[plan.index] || null;
return {
action: 'on-stop',
agent,
member: Boolean(member),
advanced,
next: nextStep,
assign: nextStep
? { hint: `Spawn or resume agent "${nextStep.agent}" for the next plan step`, agent: nextStep.agent }
: { hint: 'Plan complete — lead should synthesize' },
};
});
}
export function shutdownTeam(projectRoot, opts) {
return updateTeam(projectRoot, opts.team, (t) => {
t.status = 'shutdown';
t.shutdownAt = nowIso();
for (const m of Object.values(t.members || {})) m.status = 'shutdown';
return { action: 'shutdown', teamId: t.id };
});
}
/**
* Active teams that list `agent` as a member. The SubagentStop hook uses this
* when the spawn description carries no @team: exactly one match is used,
* several matches are reported as ambiguous.
*/
export function teamsWithMember(projectRoot, agent) {
const root = teamsRoot(projectRoot);
if (!realDirExists(root)) return [];
const out = [];
for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
if (!ent.isDirectory() || !NAME_RE.test(ent.name)) continue;
const t = readJson(path.join(root, ent.name, 'team.json'));
if (t && t.status === 'active' && t.members && t.members[agent]) out.push(ent.name);
}
return out.sort();
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const out = { _: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
out[key] = true;
} else {
out[key] = next;
i++;
}
} else {
out._.push(a);
}
}
return out;
}
const print = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
const USAGE = [
'create --name <team> [--topology hierarchical] [--max-agents 8] [--force]',
'spawn --team <team> --agent <name> [--role <role>] [--prompt "..."] [--next a,b]',
'send --team <team> --to <agent|*> --message "..." [--summary s] [--from lead] [--priority 0-999]',
'inbox --team <team> --agent <name> [--peek]',
'status --team <team>',
'plan --team <team> --steps \'["architect","developer","tester"]\'',
'on-stop --team <team> --agent <name>',
'shutdown --team <team>',
];
export async function runCli(argv, env = process.env) {
const args = parseArgs(argv);
const cmd = args._[0];
const projectRoot = path.resolve(
typeof args.root === 'string' ? args.root : env.CLAUDE_PROJECT_DIR || env.GROK_WORKSPACE_ROOT || process.cwd(),
);
const team = args.team || args.name;
switch (cmd) {
case 'create':
return createTeam(projectRoot, {
name: team,
topology: args.topology,
maxAgents: args['max-agents'] ?? args.maxAgents,
host: args.host,
force: args.force === true,
});
case 'spawn':
return spawnMember(projectRoot, {
team,
agent: args.agent || args.member,
role: args.role,
prompt: args.prompt || args.message,
next: args.next,
});
case 'send':
return sendMessage(projectRoot, {
team,
to: args.to,
from: args.from,
summary: args.summary,
message: args.message ?? args.content,
type: args.type,
priority: args.priority,
});
case 'inbox':
return readInbox(projectRoot, { team, agent: args.agent || args.to, peek: args.peek === true });
case 'status':
return teamStatus(projectRoot, { team });
case 'plan':
return setPlan(projectRoot, { team, steps: args.steps });
case 'on-stop':
return onStop(projectRoot, { team, agent: args.agent });
case 'shutdown':
return shutdownTeam(projectRoot, { team });
default:
return null;
}
}
async function main() {
let result;
try {
result = await runCli(process.argv.slice(2));
} catch (err) {
print({ ok: false, error: err.message || String(err) });
process.exit(1);
return;
}
if (result === null) {
print({ ok: false, error: 'usage', commands: USAGE });
process.exit(process.argv[2] ? 1 : 0);
return;
}
print({ ok: true, ...result });
}
function invokedDirectly() {
if (!process.argv[1]) return false;
try {
return import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href;
} catch {
return false;
}
}
if (invokedDirectly()) main();
@@ -0,0 +1,287 @@
/**
* Storage layer for the host-agnostic Agent Teams bus (ADR-402).
* Imported by grok-team-bus.mjs; not a CLI.
*
* Layout (all under <project>/.claude-flow/teams/<team>/):
* team.json roster, plan, status (lock + atomic rename)
* team.lock cross-process lock (O_EXCL file + token)
* mailbox/<agent>/*.json pending messages, named <priority>_<id>.json
* mailbox/<agent>/archive/ drained messages
*/
import fs from 'node:fs';
import path from 'node:path';
export const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
// A waiter must never give up before the lock it's waiting on can even be
// considered stale — otherwise every spawn attempted in the gap between
// "waiter gives up" and "lock is stale" fails outright even though nothing
// is actually stuck (ADR-402 round-2 review, N3). So LOCK_WAIT_MS >=
// LOCK_STALE_MS, always.
//
// Both are also sized to fit inside the SubagentStop hook's own 5s command
// timeout (templates/grok/hooks/subagent-stop-team.json): worst case, the
// hook waits out the full stale window, breaks the lock, and runs its (tiny,
// sub-millisecond) critical section — that must land comfortably under 5s
// including node startup. 4s stale / 4.5s wait leaves ~0.5s of margin for
// process startup and the break-then-acquire dance, while still being far
// longer than any legitimate holder ever needs (team.json read-modify-write
// is the only thing done under this lock).
const LOCK_WAIT_MS = 4_500;
const LOCK_STALE_MS = 4_000;
// ---------------------------------------------------------------------------
// Names, paths, and filesystem safety
// ---------------------------------------------------------------------------
export function safeName(name, field = 'name') {
if (typeof name !== 'string' || !NAME_RE.test(name)) {
throw new Error(`Invalid ${field} "${name}" — use 1-64 alphanumeric, dash, underscore (must start alphanumeric)`);
}
return name;
}
export function teamsRoot(projectRoot) {
return path.join(projectRoot, '.claude-flow', 'teams');
}
export function teamDir(projectRoot, team) {
return path.join(teamsRoot(projectRoot), safeName(team, 'team'));
}
export function teamFile(projectRoot, team) {
return path.join(teamDir(projectRoot, team), 'team.json');
}
export function mailboxDir(projectRoot, team, agent) {
return path.join(teamDir(projectRoot, team), 'mailbox', safeName(agent, 'agent'));
}
/**
* Create `dir` and every missing parent below `base`, refusing to pass through
* a symlink. A planted symlink would otherwise redirect writes outside the
* project.
*/
export function ensureRealDir(base, dir) {
const rel = path.relative(base, dir);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Refusing path outside ${base}: ${dir}`);
}
let cur = base;
fs.mkdirSync(base, { recursive: true, mode: 0o700 });
assertRealDir(cur);
for (const part of rel.split(path.sep).filter(Boolean)) {
cur = path.join(cur, part);
try {
fs.mkdirSync(cur, { mode: 0o700 });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
assertRealDir(cur);
}
}
export function assertRealDir(dir) {
const st = fs.lstatSync(dir);
if (st.isSymbolicLink() || !st.isDirectory()) {
throw new Error(`Refusing to use ${dir}: not a real directory (symlink?)`);
}
}
/** Existing real directory → true; missing → false; symlink/file → throws. */
export function realDirExists(dir) {
try {
assertRealDir(dir);
return true;
} catch (err) {
if (err.code === 'ENOENT') return false;
throw err;
}
}
export function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return null;
}
}
/** Write via a unique temp file + rename so readers never see a torn file. */
export function writeJsonAtomic(file, data) {
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
for (let attempt = 0; ; attempt++) {
try {
fs.renameSync(tmp, file);
return;
} catch (err) {
// Windows can refuse a rename over a file another process has open.
if (attempt >= 20 || (err.code !== 'EPERM' && err.code !== 'EBUSY' && err.code !== 'EACCES')) {
try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
throw err;
}
sleepMs(10);
}
}
}
/**
* Synchronous sleep via Atomics.wait — blocks this thread (and, in an MCP
* server, every other in-flight request on it) for `ms`. Kept only for the
* tiny (<=200ms worst case, ~20 attempts x 10ms), bounded Windows
* rename-retry in writeJsonAtomic, which is a rare edge case and not worth
* threading a Promise through every atomic write for. Anything that can
* block for the length of a lock wait — potentially seconds — must use
* sleepMsAsync instead (ADR-402 round-2 review, N3).
*/
export function sleepMs(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
/** Non-blocking sleep: yields to the event loop instead of the whole thread. */
export function sleepMsAsync(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function nowIso() {
return new Date().toISOString();
}
// ---------------------------------------------------------------------------
// Lock: one team.json writer at a time, across processes
// ---------------------------------------------------------------------------
/**
* Run `fn` holding the team lock: a `team.lock` file created with O_EXCL and
* holding a random token. A lock older than LOCK_STALE_MS (a crashed holder)
* is broken by renaming it aside and checking the token: if a live holder
* re-took the lock in between, the rename caught theirs, so it is linked back
* (link fails rather than overwrite) and this waiter keeps waiting. Release
* removes the file only while it still holds our token.
*
* Async: the wait loop yields via sleepMsAsync (setTimeout), not a blocking
* Atomics.wait, so an MCP server (or any other caller) keeps serving other
* requests while this call waits for the lock (ADR-402 round-2 review, N3).
* `fn` itself still runs synchronously once the lock is held — team.json
* read-modify-write is fast enough that yielding mid-critical-section isn't
* worth the added complexity.
*/
export async function withTeamLock(projectRoot, team, fn) {
const dir = teamDir(projectRoot, team);
if (!realDirExists(dir)) throw new Error(`Team "${team}" not found — run create first`);
const lock = path.join(dir, 'team.lock');
const token = `${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}`;
const deadline = Date.now() + LOCK_WAIT_MS;
for (let wait = 2; ; wait = Math.min(wait * 2, 50)) {
try {
fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 });
break;
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
breakStaleLock(lock, token);
if (Date.now() > deadline) throw new Error(`Timed out waiting for the lock on team "${team}"`);
await sleepMsAsync(wait + Math.floor(Math.random() * wait));
}
try {
return fn();
} finally {
try {
if (fs.readFileSync(lock, 'utf8') === token) fs.rmSync(lock, { force: true });
} catch {
/* already gone */
}
}
}
/** True when the lock token names a pid that still exists on this host. */
function lockHolderAlive(token) {
const pid = Number(String(token).split('.')[0]);
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
// EPERM: the process exists and we cannot signal it. ESRCH: it is gone.
return err.code === 'EPERM';
}
}
/**
* Break `lock` if it's still stale. Re-verifies the token and mtime we
* observed as stale immediately before renaming anything: without this, a
* waiter that read a stale token, then got preempted while another waiter
* broke the same lock and a fresh holder re-acquired it, would rename that
* LIVE lock out from under its holder — and the window that opens between
* the rename and the "put it back" recovery lets a fourth party grab the
* vacated slot, leaving two holders at once (ADR-402 round-2 review, N2).
* If anything has changed since our first observation, someone else already
* handled it: back off without touching the file and let the caller's own
* wait loop retry. A token whose pid is still alive is left alone even when
* the file is older than LOCK_STALE_MS (round-3 review).
*/
export function breakStaleLock(lock, token) {
let seen, mtime;
try {
mtime = fs.statSync(lock).mtimeMs;
if (Date.now() - mtime <= LOCK_STALE_MS) return;
seen = fs.readFileSync(lock, 'utf8');
} catch {
return; // released meanwhile
}
let mtime2, seen2;
try {
mtime2 = fs.statSync(lock).mtimeMs;
seen2 = fs.readFileSync(lock, 'utf8');
} catch {
return; // released meanwhile
}
if (mtime2 !== mtime || seen2 !== seen) return; // changed underneath us — back off
// A live holder is not stale, however old the file is. The lock is not
// refreshed while held, so a stall longer than LOCK_STALE_MS (SIGSTOP,
// sleep, a hook killed mid-update) used to let the next writer break it
// and drop one of the two updates. The token starts with the holder's
// pid; if that pid is still alive on this host, leave the lock alone
// (ADR-402 round-3 review). A dead or unparsable pid keeps the mtime rule.
if (lockHolderAlive(seen2)) return;
const aside = `${lock}.stale.${token}`;
try {
fs.renameSync(lock, aside);
} catch {
return; // another waiter broke it first
}
try {
if (fs.readFileSync(aside, 'utf8') !== seen) {
// Same-millisecond collision the re-verify above couldn't rule out:
// put the live lock back if the slot is still free.
try { fs.linkSync(aside, lock); } catch { /* slot taken; that holder's release is a no-op */ }
}
} finally {
fs.rmSync(aside, { force: true });
}
}
export function loadTeam(projectRoot, team) {
const t = readJson(teamFile(projectRoot, team));
if (!t) throw new Error(`Team "${team}" not found — run create first`);
return t;
}
/** Read-modify-write team.json under the lock. `mutate` returns the result. */
export function updateTeam(projectRoot, team, mutate) {
return withTeamLock(projectRoot, team, () => {
const t = loadTeam(projectRoot, team);
const result = mutate(t);
writeJsonAtomic(teamFile(projectRoot, team), t);
return result;
});
}
export function assertActive(t) {
if (t.status !== 'active') {
throw new Error(`Team "${t.name}" is ${t.status}; create it again (force) to reuse the name`);
}
}
@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* One "what is loaded" line for every host.
*
* grok ~/.grok/config.toml [ui.status_line] command. Project config
* cannot set this (Grok 1.0.41 reads [ui] from the user file only).
* SessionStart stdout is ignored, so a hook cannot print the line.
* `ruflo init --grok --grok-statusline` installs a copy of this
* script at ~/.grok/ruflo/host-statusline.mjs and points the row at
* that absolute path; it reports on whichever repo Grok is in.
* claude .claude/settings.json statusLine → .claude/helpers/statusline.cjs
* (the rich row). This script is the same facts, not a replacement.
* codex no status row. `node scripts/host-statusline.mjs --host codex`
* prints the line; nothing in Codex displays it for you.
*
* Grok pipes a JSON payload on stdin. A tty (a manual run) is left alone.
* node scripts/host-statusline.mjs
* node scripts/host-statusline.mjs --json
* node scripts/host-statusline.mjs --host claude
*/
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
const args = process.argv.slice(2);
const JSON_OUT = args.includes('--json');
const hostFlag = args.indexOf('--host');
const HOST = (hostFlag >= 0 ? args[hostFlag + 1] : 'grok').toLowerCase();
const ROOT = resolve(process.env.RUFLO_HOST_ROOT || process.cwd());
export const HOST_STATUS = {
grok: {
surface: 'ui.status_line',
scope: 'user',
command: 'node "<home>/.grok/ruflo/host-statusline.mjs"',
},
claude: {
surface: 'statusLine',
scope: 'project',
command: 'node .claude/helpers/statusline.cjs',
},
codex: {
surface: null,
scope: null,
command: 'node scripts/host-statusline.mjs --host codex',
},
};
function has(rel) {
return existsSync(join(ROOT, rel));
}
// ---------------------------------------------------------------------------
// Minimal, comment-aware TOML table reader.
//
// This script is a zero-dependency copy: `init --grok --grok-statusline`
// writes it to ~/.grok/ruflo/host-statusline.mjs, and `init --grok` also
// copies it to <project>/scripts/host-statusline.mjs — both live outside any
// node_modules tree, so (unlike grok-generator.ts, which is part of the
// installed @claude-flow/cli package) it can't `import '@iarna/toml'`
// without risking "Cannot find package" at the exact moment Grok tries to
// render the status line. The previous version matched `trusted = true` /
// `[mcp_servers.name]` via substring/regex on the raw file text, which
// happily matched inside a `#` comment and never noticed a sibling
// `enabled = false` key. This walks real `[section]` boundaries and strips
// comments outside of string literals first, instead (ADR-402 round-2
// review, minor item 3). It is not a general TOML parser — only enough of
// one for the two shapes this file actually reads: table headers (with
// quoted or bare dotted keys) and simple `key = true|false` booleans.
// ---------------------------------------------------------------------------
function stripComment(line) {
let quote = null;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quote) {
if (c === '\\' && quote === '"') { i++; continue; }
if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'") { quote = c; continue; }
if (c === '#') return line.slice(0, i);
}
return line;
}
/** Splits a dotted TOML key on top-level dots only (not dots inside quotes), then dequotes each part. */
function splitDottedKey(raw) {
const parts = [];
let cur = '';
let quote = null;
for (const c of raw) {
if (quote) {
cur += c;
if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'") { quote = c; cur += c; continue; }
if (c === '.') { parts.push(cur.trim()); cur = ''; continue; }
cur += c;
}
parts.push(cur.trim());
return parts.map((p) => p.replace(/^["']|["']$/g, ''));
}
/**
* '[a."b.c"]' or '[a.b]' → normalized key 'a.b.c' (dequoted parts joined by '.'),
* or null if not a table header. A `]` inside a quoted key is part of the key
* (`[folders."/x/a]b"]`), not the end of the header.
*/
function sectionHeaderKey(line) {
const trimmed = line.trim();
if (!trimmed.startsWith('[') || trimmed.startsWith('[[')) return null; // array-of-tables: not modeled
let quote = null;
let end = -1;
for (let i = 1; i < trimmed.length; i++) {
const c = trimmed[i];
if (quote) {
if (c === '\\' && quote === '"') { i++; continue; }
if (c === quote) quote = null;
continue;
}
if (c === '"' || c === "'") { quote = c; continue; }
if (c === ']') { end = i; break; }
}
if (end < 0 || trimmed.slice(end + 1).trim() !== '') return null;
const inner = trimmed.slice(1, end).trim();
if (!inner) return null;
return splitDottedKey(inner).join('.');
}
/** { normalizedSectionPath -> { key -> rawValueText } } for every `[table]` in the file. */
function parseTomlTables(text) {
const tables = new Map([['', {}]]);
let current = '';
for (const rawLine of text.split('\n')) {
const line = stripComment(rawLine).trim();
if (!line) continue;
const header = sectionHeaderKey(line);
if (header !== null) {
current = header;
if (!tables.has(current)) tables.set(current, {});
continue;
}
const kv = /^([^=]+?)\s*=\s*(.+)$/.exec(line);
if (!kv) continue;
tables.get(current)[kv[1].trim().replace(/^["']|["']$/g, '')] = kv[2].trim();
}
return tables;
}
function tomlBool(raw) {
if (raw === 'true') return true;
if (raw === 'false') return false;
return undefined;
}
function sectionTrusted(text, folder) {
const tables = parseTomlTables(text);
const table = tables.get(sectionHeaderKey(`[folders."${folder}"]`));
return table ? tomlBool(table.trusted) === true : false;
}
function grokTrusted() {
const file = join(homedir(), '.grok', 'trusted_folders.toml');
if (!existsSync(file)) return false;
try {
return sectionTrusted(readFileSync(file, 'utf8'), ROOT);
} catch {
return false;
}
}
/** A `[mcp_servers.<name>]` table exists and is not explicitly `enabled = false`. */
function configHasServer(file, name) {
if (!existsSync(file)) return false;
let tables;
try {
tables = parseTomlTables(readFileSync(file, 'utf8'));
} catch {
return false;
}
const table = tables.get(sectionHeaderKey(`[mcp_servers.${name}]`));
if (!table) return false;
return tomlBool(table.enabled) !== false;
}
function readStdinPayload() {
if (process.stdin.isTTY) return null;
try {
const raw = readFileSync(0, 'utf8');
if (!raw.trim().startsWith('{')) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function snapshot(host) {
if (host === 'claude') {
const settings = join(ROOT, '.claude', 'settings.json');
let wired = false;
if (existsSync(settings)) {
try {
wired = Boolean(JSON.parse(readFileSync(settings, 'utf8')).statusLine?.command);
} catch {
wired = false;
}
}
return {
host,
surface: HOST_STATUS.claude.surface,
loaded: {
statusLine: wired,
helper: has('.claude/helpers/statusline.cjs'),
},
};
}
if (host === 'codex') {
const homeCfg = join(homedir(), '.codex', 'config.toml');
return {
host,
surface: null,
loaded: {
mcp: configHasServer(homeCfg, 'ruflo') || configHasServer(homeCfg, 'claude-flow'),
},
};
}
const agents = ['ruflo-architect', 'ruflo-coder', 'ruflo-tester', 'ruflo-reviewer'].filter((name) =>
has(join('.grok', 'agents', `${name}.md`)),
);
const skills = ['agent-teams-grok'].filter((name) =>
has(join('.grok', 'skills', name, 'SKILL.md')),
);
const projectCfg = join(ROOT, '.grok', 'config.toml');
const userCfg = join(homedir(), '.grok', 'config.toml');
return {
host: 'grok',
surface: HOST_STATUS.grok.surface,
loaded: {
mcp: configHasServer(projectCfg, 'ruflo') || configHasServer(projectCfg, 'claude-flow') || configHasServer(userCfg, 'ruflo'),
rules: has('.grok/rules/ruflo-grok.md'),
agents: agents.length,
skills: skills.length,
hook: has('.grok/hooks/subagent-stop-team.json'),
trusted: grokTrusted(),
},
};
}
function lineFor(snap, stdin) {
if (snap.host === 'claude') {
const ok = snap.loaded.statusLine && snap.loaded.helper;
return ok ? 'RuFlo loaded │ claude statusLine' : 'RuFlo │ claude statusLine missing';
}
if (snap.host === 'codex') {
return snap.loaded.mcp ? 'RuFlo │ codex mcp configured (no status row)' : 'RuFlo │ codex mcp not configured';
}
const L = snap.loaded;
const gaps = [];
if (!L.mcp) gaps.push('mcp');
if (!L.rules) gaps.push('rules');
if (L.agents < 4) gaps.push(`agents ${L.agents}/4`);
if (L.skills < 1) gaps.push('skill agent-teams-grok');
if (!L.hook) gaps.push('hook');
if (!L.trusted) gaps.push('untrusted');
const head = gaps.length ? `RuFlo │ missing ${gaps.join(', ')}` : 'RuFlo loaded │ ruflo │ rules │ 4 agents │ skill │ hook │ trusted';
const model = stdin?.model?.display_name;
const pct = stdin?.context_window?.used_percentage;
const tail = [model, pct == null ? '' : `${pct}% ctx`].filter(Boolean).join(' │ ');
return tail ? `${head} │ ${tail}` : head;
}
const snap = snapshot(HOST);
const text = lineFor(snap, readStdinPayload());
if (JSON_OUT) {
process.stdout.write(JSON.stringify({ ...snap, line: text, map: HOST_STATUS }, null, 2) + '\n');
} else if (text) {
process.stdout.write(`${text}\n`);
}
@@ -0,0 +1,98 @@
---
name: agent-teams-grok
description: >
Run Ruflo-style named Agent Teams on Grok Build using the host-agnostic team bus
(scripts/grok-team-bus.mjs). Use when coordinating multi-agent pipelines, feature
development with architect→coder→tester→reviewer, or replacing Claude SendMessage.
---
# Agent Teams on Grok (ADR-402)
Claude `SendMessage` / TeammateTool are **not** available. Prefer **Ruflo MCP** `team_*` tools; CLI bus is the fallback.
## Preferred: MCP tools
| Tool | Purpose |
|------|---------|
| `team_create` | Create team + topology |
| `team_plan` | Ordered pipeline steps |
| `team_spawn` | Register agent; returns **spawn plan** for `spawn_subagent` |
| `team_send` / `team_broadcast` | Handoff messages |
| `team_inbox` | Drain/peek mailbox |
| `team_status` / `team_on_stop` / `team_shutdown` | Lifecycle |
Discover: `search_tool` query `team_create`. Call via `use_tool` as `ruflo__team_create` etc.
## Quick start (CLI fallback)
```bash
# 1. Create team
node scripts/grok-team-bus.mjs create --name feature-x --topology hierarchical
# 2. Register plan
node scripts/grok-team-bus.mjs plan --team feature-x \
--steps '["architect","developer","tester","reviewer"]'
# 3. Register agents (prints spawnPlan JSON for spawn_subagent)
node scripts/grok-team-bus.mjs spawn --team feature-x --agent architect --role architect \
--prompt "Design <feature>. Handoff to developer." --next developer
node scripts/grok-team-bus.mjs spawn --team feature-x --agent developer --role developer \
--prompt "Implement from architect design." --next tester
node scripts/grok-team-bus.mjs spawn --team feature-x --agent tester --role tester \
--prompt "Test implementation." --next reviewer
node scripts/grok-team-bus.mjs spawn --team feature-x --agent reviewer --role reviewer \
--prompt "Review code and tests."
```
## Lead (you) then
Checked against **Grok Build 1.0.41**. Pass only the live spawn arguments:
```
spawn_subagent({
prompt: spawnPlan.prompt,
description: spawnPlan.host.grok.spawn.description,
background: spawnPlan.host.grok.spawn.background,
isolation: spawnPlan.host.grok.spawn.isolation,
})
```
1. Parse `spawnPlan.host.grok.spawn` (and `prompt`). Call **`spawn_subagent`** in **one message** for every agent (`background: true`).
2. Leave `host.grok.advisory` on the plan. `capability_mode` and `subagent_type` are not spawn arguments on this Grok. The prompt carries the read-only or worktree constraint. `isolation` is the knob Grok enforces.
3. `.grok/agents/ruflo-*` are session profiles (`grok --agent-profile ruflo-coder` or `/agents`). `spawn_subagent` does not select them. An omitted type is `general-purpose`.
4. Children must not call `spawn_subagent`. Nesting depth is 1. The lead spawns the whole pipeline.
5. Pass `description` unchanged (`<role>:<agent>@<team>`): the `SubagentStop` hook reads it to run `on-stop` for that team. Then spawn or resume the next step if needed.
6. Synthesize results; `shutdown` the team. A shut-down team refuses spawn and send; `team_create --force` reopens the name.
Every call names its team: mail is per team, and several teams can run at once. `team_spawn` stops at the team's `maxAgents`.
If `grok --version` is newer than 1.0.41, re-read `~/.grok/docs/user-guide/16-subagents.md` (Spawning Subagents) before forwarding extra keys. A key the schema dropped fails the spawn.
## Defaults
| Role | Prompt constraint (`advisory.capability_mode`) | `isolation` (passed through) |
|------|------------------------------------------------|------------------------------|
| architect / reviewer | read-only | `none` |
| developer / tester | full tools | **`worktree`** |
## Messaging
```bash
node scripts/grok-team-bus.mjs send --team feature-x --to developer \
--from architect --summary "design" --message "..."
node scripts/grok-team-bus.mjs inbox --team feature-x --agent developer
node scripts/grok-team-bus.mjs status --team feature-x
```
## Also use Ruflo MCP when live
- `memory_search` / `memory_store` with namespace `team:feature-x`
- `swarm_init` for coordination records (then **you** still execute)
## Anti-patterns
- Waiting for `swarm start` to write code
- Inventing SendMessage
- Passing `capability_mode` or `subagent_type` into `spawn_subagent` (they are advisory on Grok Build 1.0.41)
- Parallel coders on the same tree without worktrees
- A child calling `spawn_subagent` (depth limit is 1)
@@ -0,0 +1,161 @@
# ADR-402: Host-Agnostic Agent Teams (Grok-first, Claude-portable)
**Status:** Proposed
**Date:** 2026-07-20
**Deciders:** Ruflo maintainers
**Related:** ADR-018 (Claude Code integration), teammate-plugin, swarm-comms mailbox, RuvNet Brain grounding, `init --codex` pattern
---
## Context
Ruflo’s multi-agent “Agent Teams” UX on Claude Code depends on host features:
- Named agents + proprietary **`SendMessage`** mailbox
- Claude `Task` tool / TeammateTool (`@claude-flow/teammate-plugin`, `~/.claude/teams/`)
- Optional `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`
Grok Build already provides strong primitives that Claude Agent Teams lack or only partially have:
| Capability | Grok | Claude Agent Teams |
|------------|------|--------------------|
| Parallel children | `spawn_subagent` + background | Task / teammates |
| Isolation | **`isolation: worktree`** | Shared tree (race-prone) |
| Least privilege | `capability_mode` | Soft / prompt-level |
| Stage continuity | `resume_from` | Message-only handoff |
| Hooks | Claude-compat + SubagentStop | TeammateIdle / TaskCompleted |
| Skills | `.agents` + `.claude` discovery | Claude skills |
Today’s **teammate-plugin is Claude-bound** (peerDep on Claude Code, spawn returns `AgentInput` for Claude Task). A Grok-only prompt convention is not enough: teams, skills, and learning are product-critical and must work **better than Claude Code**.
Existing seed in-tree: `.claude/helpers/swarm-comms.sh` already implements a **filesystem mailbox** under `.claude-flow/swarm/mailbox/`.
## Decision
1. **Put the Agent Teams bus in Ruflo, not in the host.**
Comms are MCP + on-disk/AgentDB state. Hosts only execute spawns and run hooks.
2. **Ship `team_*` MCP tools** (host-agnostic contract):
| Tool | Purpose |
|------|---------|
| `team_create` | Create team + topology + max members |
| `team_spawn` | Register teammate; return **spawn plan** for the host |
| `team_send` | Enqueue message to named agent (or `*`) |
| `team_inbox` | Drain / peek mailbox for agent |
| `team_broadcast` | Fan-out |
| `team_plan` | Steps + dependencies (pipeline) |
| `team_status` | Members, queues, plan progress |
| `team_on_stop` | Idle-assign / train (hook entry) |
| `team_shutdown` | Graceful teardown |
3. **Spawn plan contract** (host adapter):
```json
{
"teamId": "team_…",
"name": "architect",
"role": "architect",
"prompt": "…comms protocol embedded…",
"host": {
"grok": {
"subagent_type": "general-purpose",
"capability_mode": "read-only",
"isolation": "none",
"background": true
},
"claude": {
"taskType": "system-architect",
"note": "optional back-compat path"
}
},
"next": ["developer"]
}
```
Grok lead calls `spawn_subagent` with the plan; it does **not** need `SendMessage`.
4. **Storage**
- Team state: project-local `.claude-flow/teams/{teamId}/` (not `~/.claude/teams/`)
- Mailbox: per team, `.claude-flow/teams/{teamId}/mailbox/{agent}/` (+ optional AgentDB namespace `team:{id}`). Not the shared `.claude-flow/swarm/mailbox/`: an agent name is only unique within its team.
- Learning: existing `post-task` / memory_store patterns
5. **Grok defaults that beat Claude**
- Write agents: `isolation: worktree`
- Research/review: `read-only` / explore
- Pipeline: short cycles + `team_on_stop` idle assign
- Optional `resume_from` for stage continuity when mailbox payload is large
6. **Grounding (RuvNet Brain)** is in scope for the same host effort:
`search_ruvnet` (or forge-mcp) + intent/action policy so the model does not drift to training-prior infra.
7. **Product entry:** `npx ruflo init --grok` (later) mirrors `init --codex` — writes `.grok/config.toml`, rules, agents, MCP registration.
## Consequences
### Positive
- Same team semantics on Grok, Codex, Claude (Claude becomes one adapter).
- Worktree isolation reduces multi-agent file conflicts vs Claude shared-tree teams.
- Federation can later ride the same bus.
- Upstreamable: host-agnostic core can be proposed back to ruvnet/ruflo.
### Negative / costs
- Must implement and maintain `team_*` tools and adapters.
- Grok may not inject `UserPromptSubmit` stdout the way Claude injects `additionalContext` — route/brain hooks need file or MCP fallbacks.
- Skill/tool surface must be curated to avoid context drowning (300+ MCP tools + 100+ skills).
### Neutral
- teammate-plugin remains for native Claude TeammateTool users until migrated to the agnostic bus.
- CLAUDE.md Claude-specific examples stay valid for Claude hosts; Grok overlay (`.grok/rules/ruflo-grok.md`) takes precedence on Grok.
## Implementation plan (summary)
1. Phase 0: project `.grok/config.toml` + rules + MCP smoke — **done**.
2. Phase 1: mailbox-backed `team_create/send/inbox/status` MVP — **done** (CLI + MCP).
3. Phase 2: `team_spawn` spawn plans + Grok agent defs + SubagentStop → `team_on_stop` — **done**.
4. Phase 3: RuvNet Brain MCP + grounding rules — **done** (`KB_DIR` required).
5. Phase 4: `init --grok` — **done**. A host conformance bench is proposed separately.
## Alternatives considered
| Alternative | Why rejected |
|-------------|--------------|
| Prompt-only “pretend SendMessage” | No durable bus, no idle assign, not better than Claude |
| Depend on Grok adding SendMessage | Speculative; bus should not be proprietary |
| Codex-style “single executor + swarm records only” | Fails requirement: agent teams + skills are critical |
| Fork forever without agnostic bus | Blocks upstream and multi-host |
## Amendment (2026-09-27) — Grok Build 1.0.41
Re-checked against `grok 1.0.41` and `~/.grok/docs/user-guide/` on that binary. The spawn example in the Decision section above matches Grok as of 2026-07-20. The live tool no longer matches it.
- `spawn_subagent` accepts `prompt`, `description`, `background`, `isolation`, and optionally `cwd`, `resume_from`, and `model`. It does not accept `subagent_type` or `capability_mode`. An omitted type is `general-purpose`. Capability is a property of the agent definition, which spawn cannot select.
- Nesting depth is 1. Only the lead calls `spawn_subagent`.
- Project `.grok/config.toml` contributes `[mcp_servers]`, `[plugins]`, `[permission]`, and `[mcp].max_output_bytes`. `[subagents]` in that file is ignored.
- `.grok/agents/*.md` are session profiles (`--agent-profile`, `/agents`).
- MCP tools are still reached through `search_tool` / `use_tool` (`ruflo__team_create`). `grok mcp add` still defaults to user scope; `--scope project` / `-s project` writes `./.grok/config.toml`.
- Folder trust (`/hooks-trust`, `grok --trust`) gates project MCP, hooks, skills, and rules together.
`team_spawn` now returns `host.grok.spawn` (the arguments to pass) and `host.grok.advisory` (role constraint, not a spawn argument). The prompt states the read-only or worktree constraint. `isolation: "worktree"` remains the enforced isolation knob. Operator steps and the re-check list live in `v3/@claude-flow/cli/templates/grok/docs/README.md` (copied to `docs/grok/README.md` by `init --grok`).
## Amendment (2026-09-28) — review of #3512
- One implementation: `templates/grok/scripts/grok-team-bus.mjs` is the store. The `team_*` MCP tools import it; `init --grok` copies it into a project as a CLI; the SubagentStop hook imports it. No repo-root copies.
- `team.json` writes take a cross-process lock (`team.lock`, an O_EXCL file with an owner token; a stale lock is broken by rename and token check, never by a blind delete) and go through temp file + rename. Mail is per team (`teams/{id}/mailbox/{agent}/`); a drain claims each file by rename, so concurrent drains are disjoint. Mailbox paths that are symlinks are refused.
- `team_shutdown` closes the team (spawn, send, plan, on-stop refuse); `team_spawn` enforces `maxAgents`; a broadcast with no members is refused; `priority` is an integer 0-999 in a zero-padded filename.
- The spawn description is `<role>:<agent>@<team>`; the SubagentStop hook parses it, and reports on stderr (exit 1) when a team subagent cannot be recorded or the team is ambiguous.
- `init --grok` writes only inside the project unless `--grok-statusline` is passed; then it installs `~/.grok/ruflo/host-statusline.mjs` and merges `[ui.status_line]` into `~/.grok/config.toml` with a TOML parser, or prints the snippet when it cannot merge safely. `--dry-run` lists what would be written. The project MCP entry is pinned to the running Ruflo version (the one that ships `team_*`), not `@latest`.
## References
- [RuvNet Brain](https://isovision.ai/ruvnet-brain/) — grounding at intent + action; `search_ruvnet`
- `v3/plugins/teammate-plugin` — Claude-bound prior art
- `.claude/helpers/swarm-comms.sh` — mailbox seed
- `v3/@claude-flow/cli/templates/grok/rules/ruflo-grok.md` — Grok host doctrine
- Grok user guide: MCP, hooks, subagents, skills, Claude compat
)