coordinator
Tier 2. Depends on @titan-design/agent-dispatch (tier 1) for limitsSchema. zod is a peer dependency.
npm install @titan-design/coordinator zodStatus: 0.1, pure code. The package has no fs, process, network or broker access.
The problem it solves
Seat files carry front matter (schema: autonomy-seat/v1) that several tools read by hand. This package adds one primitive: seatConfigSchema, a zod schema with inferred types, so a host validates a seat's config the same way everywhere. It also folds a seat's event log into SeatState (foldSeatEvents), so seat state is computed from events rather than hand-kept, and can be tested with no broker running.
The autonomy charter carries front matter too (schema: autonomy-charter/v1): the seat roster, the hub, the hard-stop classes and the scorer defaults. charterPolicySchema types it and parseCharterPolicy validates it, so generic charter rules can live in code rather than prose.
HARD_STOPS catalogues those classes with one detection pattern each, and hardStopsIn names the classes a task's done_when (or any text) touches, so every host detects hard stops the same way. resolveSeatPolicy turns the charter and one seat into that seat's effective hard stops and scorer defaults: the stricter rule wins, and a seat never removes a global hard stop.
A coordinator for another user needs one document instead of a tree of seat files: titan-coordinator/v1. coordinatorConfigSchema composes it from the schemas above and agent-dispatch's limitsSchema, and checkCoordinatorConfig checks the references between its sections.
Changes to that document are a revision log. applyEdit sets one schema-declared field on a copy and re-checks the whole document, diffDocuments lists the changed leaves, and verifyChain checks that each revision names the previous one's revisionSha as its parent, so a forked or rewritten log fails. EDITABLE_FIELDS is the allowlist of fields an editor may change, each with its bounds and when its readers pick the change up; checkEditableValue refuses any path no row matches.
When to reach for it
Use it to validate or type a seat's front matter after you have parsed the YAML yourself. Loading seat files, jobs and placement are not here; they wait for the host work.
Example
import { seatConfigSchema, type SeatConfig } from "@titan-design/coordinator";
const seat: SeatConfig = seatConfigSchema.parse(frontMatterObject);import { foldSeatEvents } from "@titan-design/coordinator";
const state = foldSeatEvents(
[
{ kind: "teleport" },
{ kind: "claim", owner: "sx-a", worktree: "/w/one", patterns: ["src/a.ts"] },
{ kind: "background", id: "watch", command: "node watch.js", cwd: "/w/one" },
],
{ tmpdir: process.env.TMPDIR },
);
// state.generation === 1; state.errors lists any refused or malformed eventimport { parseCharterPolicy } from "@titan-design/coordinator";
const result = parseCharterPolicy(charterFrontMatterObject);
if (!result.ok) {
// e.g. [{ code: "missing", path: "defaults.teleport_k", message: "..." }]
console.error(result.errors);
}import { hardStopsIn } from "@titan-design/coordinator";
hardStopsIn("Edit CLAUDE.md, then deploy.");
// ["deploy", "config-edit"]
hardStopsIn("Edit CLAUDE.md, then deploy.", policy.hard_stops.filter((s) => s !== "deploy"));
// ["config-edit"]import { resolveSeatPolicy } from "@titan-design/coordinator";
const { hard_stops, defaults } = resolveSeatPolicy(policy, seat);
// hard_stops: the charter's list, then the seat's extra_hard_stops
// defaults: the charter's, with the seat's `defaults` and weights merged over themimport { checkCoordinatorConfig } from "@titan-design/coordinator";
const result = checkCoordinatorConfig({
$schema: "titan-coordinator/v1",
owner: { seat: "operator", timezone: "UTC", channels: ["console"] },
repos: { web: { path: "~/src/web", remote: "example/web", default: "main" } },
seats: {
operator: { prefix: "op", attended: true, pool: "main",
concurrency: { implementers: 0, reviewers: 0, planners: 1 }, spend: {} },
"web-coord": { prefix: "wc", pool: "main", repos: ["web"],
concurrency: { implementers: 2, reviewers: 2, planners: 1 }, spend: {} },
},
limits: {
version: 1,
pools: { main: { config_dir: "~/.claude", ceiling_five_hour: 90, reserve_seven_day: 20 } },
},
policy: { hard_stops: ["force-push", "npm-publish"] },
});
// a seat with pool "spare" would give
// { ok: false, errors: [{ code: "reference", path: "seats.web-coord.pool", ... }] }import { applyEdit, verifyChain } from "@titan-design/coordinator";
const edit = applyEdit(document, "seats.web-coord.concurrency.implementers", 4);
// edit.ok ? edit.document : edit.errors
verifyChain(revisions);
// { ok: true, head } or errors such as { rev: 2, code: "parent", ... }import { checkEditableValue } from "@titan-design/coordinator";
checkEditableValue("coordinator", "seats.web-coord.concurrency.implementers", 65);
// { ok: false, reason: "invalid", ... }: the cap is 0..64
checkEditableValue("coordinator", "seats.web-coord.grants_extra", []);
// { ok: false, reason: "not-editable", ... }import { parseAuthored, renderAuthored } from "@titan-design/coordinator";
const result = parseAuthored(seatState.authored);
if (result.ok) {
const text = renderAuthored(result.block, new Date());
// "Continuations:\n- on pr owner/repo#12: merge once green\n\nNext/why:\n..."
}What it deliberately does not do
It reads no files, parses no YAML and runs nothing. It has no notion of jobs or placement.
Gotchas
name, prefix, pool, config_dir, concurrency and spend are required; everything else is optional. Unknown keys pass through (loose objects), so a new seat key never fails the parse, but it is also not checked. spend may be an empty object.
foldSeatEvents never throws. An unknown kind only bumps unknownEvents; a known kind that fails its schema, or a background command whose cwd or any path argument sits under /tmp, /private/tmp, the tmpdir option or a scratchpad directory, lands in errors. A claim replaces the earlier claim on the same worktree and a hold the earlier hold on the same target; patterns and reasons are never merged. Pass $TMPDIR in yourself: the fold reads no environment. A literal $TMPDIR or ${TMPDIR} in the command counts as temp space, and a tmpdir under /var/ also covers its /private real path (macOS).
Paths come from a best-effort split of the command on whitespace, quotes, = and shell operators (>, 2>, |, &&, ;), with an option glued to a path (-o/tmp/x) trimmed. URLs are skipped. Other variables are not expanded. errors[].index counts from the start of each call's events, so a fold resumed with from restarts at 0.
parseCharterPolicy never throws. It requires schema, seats (at least one), hub, hard_stops and defaults. Every hard stop must be one of HARD_STOP_CLASSES (the twelve classes); an unknown class is refused, since a misspelt one would guard nothing. In defaults the scorer terms, retire_k, teleport_k and the worktree counts are required; gate_free_bonus, stale_pr_days and heartbeat_cron are optional. Unknown keys pass through. pools and funds are deliberately not in the schema, because account limits belong to @titan-design/agent-dispatch; they pass through untyped. An error's code is missing when nothing sits at its path, and invalid otherwise.
hardStopsIn reports classes in HARD_STOPS order, not charter order, and treats a missing text as empty. The patterns favour a false hit over a miss: a stop-short task costs a score penalty, a missed hard stop costs an action a person should have taken. They are keyword patterns, not a parser, so a negation ("do not deploy") still matches. Pass among to report only the classes a charter or seat lists; a name outside HARD_STOP_CLASSES in among is ignored.
resolveSeatPolicy reads a seat's optional defaults (any charter default, each key replaced whole, so a retire_k override names all three roles), its top-level kind_weights and share_caps (merged key by key over the charter's, and over the seat's own defaults weights), and its extra_hard_stops. Hard stops are a union: the charter's list in order, then each seat extra not already in it. A hard_stops key on the seat is ignored. stop_short_factor is the lower of the charter's and the seat's, because raising it would disarm every hard stop in the scorer. Extra hard stops are free-form names, not only HARD_STOP_CLASSES. It never mutates its inputs, though the result shares nested objects with them.
checkCoordinatorConfig never throws. A schema failure returns missing or invalid errors and skips the reference checks; a document that parses then gets reference errors, each naming its key path. Lookups use own keys only, so a name such as constructor is not found by accident:
- exactly one seat has
attended: true, andowner.seatnames it; - every seat
pool,overflow_poolandpools[]entry is a key oflimits.pools, and so is every pool named insidelimits(funds,seats,profiles.<n>.poolskeys,overrides[].pools); - every
limits.seatskey is a seat; - every seat
repos[]id is a key ofrepos; a repo used by two or more seats must list each of them in itsshared_with; - seat prefixes are unique (names are unique because seats are keyed by name);
- no seat has
config_dir, which lives only on its limits pool; - every
policy.hard_stop_reposkey is inpolicy.hard_stops, whose entries must beHARD_STOP_CLASSES.
Seats are seatConfigSchema without schema, name, config_dir and repos, with repos as ids into the top-level repos map. Like the seat and charter schemas the document is loose: unknown keys pass through on read. limits is the exception: agent-dispatch's limitsSchema is strict, so an unknown limits key fails. policy.defaults may be partial or empty.
parseAuthored never throws. The 1,500-character cap is on the stored JSON text, checked before JSON.parse (too-long); authoredBlockSchema applies the same cap to an object handed in directly, measured as compact JSON without its empty sections. "" (the cleared block) and {} both read as an empty block. Unlike seat front matter the block is strict: an unknown key, a continuation on other than agent, pr, msg or time, an expires that is not a YYYY-MM-DD date, or a next_why over 5 lines is refused at its key path. Expiry is checked at render, not at parse: renderAuthored keeps a standing rule through its expires day and drops it once now's UTC day is later. Empty sections are omitted, so an empty block renders as "".
Where it came from
New. The fixtures are neutral stand-ins shaped like real seat front matter.