feat(org): agenthub org sync — CLAUDE.md is the team-structure source

Add an org service + CLI: parse a fenced ```agenthub-org YAML block from
CLAUDE.md (or AGENTS.md), validate it (one root, resolvable parents, unique
ids) and write it to .agenthub/agenthub.config.json. So the human-readable doc
drives /team: edit the block, run `agenthub org sync`, the chart adapts. Can be
wired into a post-commit hook to sync automatically when CLAUDE.md changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-08 06:12:25 +02:00
parent 0f11714873
commit c2939966b5
2 changed files with 76 additions and 0 deletions

View File

@ -7,6 +7,7 @@ import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js'; import { decisionCreate, decisionList } from './commands/decision.js';
import { messageSend, inboxList } from './commands/message.js'; import { messageSend, inboxList } from './commands/message.js';
import { agentSetup, hookContext } from './commands/agentSetup.js'; import { agentSetup, hookContext } from './commands/agentSetup.js';
import { syncOrgFromFile } from '../core/services/orgService.js';
import { delegate } from './commands/delegate.js'; import { delegate } from './commands/delegate.js';
import { serverStart } from './commands/server.js'; import { serverStart } from './commands/server.js';
import { update } from './commands/update.js'; import { update } from './commands/update.js';
@ -508,6 +509,23 @@ export function createProgram(cwd: string): Command {
}); });
program.addCommand(agentCmd); program.addCommand(agentCmd);
// ─── org sync (CLAUDE.md → team structure) ───────────────────────────────
const orgCmd = new Command('org').description('Team org-chart structure');
orgCmd
.command('sync')
.description('Sync the team org chart from an `agenthub-org` block in CLAUDE.md / AGENTS.md')
.option('--from <file>', 'Source markdown file (default: CLAUDE.md or AGENTS.md at the project root)')
.action((options: { from?: string }) => {
try {
const r = syncOrgFromFile(cwd, options.from);
console.log(`AgentHub: org synced — ${r.count} nodes from ${r.file}. /team now reflects it.`);
} catch (err) {
console.error(`AgentHub: org sync failed — ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
});
program.addCommand(orgCmd);
// Called by the SessionStart hook; prints the work-loop instruction as context. // Called by the SessionStart hook; prints the work-loop instruction as context.
program program
.command('hook-context') .command('hook-context')

View File

@ -0,0 +1,58 @@
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { parse as parseYaml } from 'yaml';
import { z } from 'zod';
import { findProjectRoot } from '../paths.js';
import { loadConfig, saveConfig } from '../config.js';
import { OrgNodeSchema, type OrgNode } from '../schema.js';
const OrgArray = z.array(OrgNodeSchema).min(1);
/** Pull the fenced ```agenthub-org … ``` YAML block out of a markdown file. */
export function extractOrgBlock(text: string): string | null {
const m = text.match(/```agenthub-org\s*\n([\s\S]*?)\n```/);
return m ? m[1] : null;
}
/** Parse + validate an org spec (YAML text) into a checked node list. */
export function parseOrg(yamlText: string): OrgNode[] {
const org = OrgArray.parse(parseYaml(yamlText));
const ids = new Set(org.map((n) => n.id));
if (ids.size !== org.length) throw new Error('duplicate node id in org block');
for (const n of org) {
if (n.parentId && !ids.has(n.parentId)) throw new Error(`node "${n.id}" → unknown parentId "${n.parentId}"`);
}
const roots = org.filter((n) => !n.parentId);
if (roots.length !== 1) throw new Error(`org must have exactly one root (found ${roots.length})`);
return org;
}
/** Resolve the source markdown file (explicit, else CLAUDE.md / AGENTS.md at root). */
export function resolveSourceFile(cwd: string, from?: string): string | undefined {
const root = findProjectRoot(cwd) ?? cwd;
if (from) return existsSync(from) ? from : undefined;
for (const cand of ['CLAUDE.md', 'AGENTS.md']) {
const p = join(root, cand);
if (existsSync(p)) return p;
}
return undefined;
}
/**
* Sync the team org chart from an `agenthub-org` block in a markdown file
* (CLAUDE.md by default) into `.agenthub/agenthub.config.json`. This makes the
* human-readable doc the single source of truth for the team structure edit
* the block, run `agenthub org sync`, and /team reflects it.
*/
export function syncOrgFromFile(cwd: string, from?: string): { count: number; file: string } {
const root = findProjectRoot(cwd) ?? cwd;
const file = resolveSourceFile(cwd, from);
if (!file) throw new Error('no source file found (looked for CLAUDE.md / AGENTS.md) — pass --from <file>');
const block = extractOrgBlock(readFileSync(file, 'utf-8'));
if (!block) throw new Error(`no \`\`\`agenthub-org block found in ${file}`);
const org = parseOrg(block);
const config = loadConfig(root);
config.org = org;
saveConfig(root, config);
return { count: org.length, file };
}