diff --git a/src/cli/index.ts b/src/cli/index.ts index 8b0eb97..a09ee3c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,6 +7,7 @@ import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { decisionCreate, decisionList } from './commands/decision.js'; import { messageSend, inboxList } from './commands/message.js'; import { agentSetup, hookContext } from './commands/agentSetup.js'; +import { syncOrgFromFile } from '../core/services/orgService.js'; import { delegate } from './commands/delegate.js'; import { serverStart } from './commands/server.js'; import { update } from './commands/update.js'; @@ -508,6 +509,23 @@ export function createProgram(cwd: string): Command { }); 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 ', '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. program .command('hook-context') diff --git a/src/core/services/orgService.ts b/src/core/services/orgService.ts new file mode 100644 index 0000000..e595ae9 --- /dev/null +++ b/src/core/services/orgService.ts @@ -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 '); + 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 }; +}