Skip to content

Latest commit

 

History

History
195 lines (156 loc) · 44 KB

File metadata and controls

195 lines (156 loc) · 44 KB

AGENTS.md — working in this repository

cezar is a parallel coding-agents orchestrator: a local cockpit (CLI + browser GUI) for running and tracking AI coding-agent tasks in a repo. You type a task, pick a workflow and a backend — Claude Code, Codex or OpenCode (experimental), or a mix per step — and watch it work live: steps, tool calls, tokens, diffs. Each task runs in its own git worktree, ends at a review gate (never auto-merges), and can be pushed as a draft PR through gh. Everything is local: no accounts, no database, no cloud — state is plain JSON, NDJSON and Markdown under .ai/cezar/. The server stack stays deliberately small: strict TypeScript (ESM, Node 20+), Hono + SSE, Zod at every boundary, and YAML workflows. The cockpit is React 19 + Vite + Tailwind v4 + shadcn/ui, compiled to static assets (the legacy vanilla UI was retired in R7). Every module is meant to be read in one sitting.

Zero config

cezar ships no config file the user must create and no setting they must set before it works. Every capability is discovered from what is already there — the repo, the environment, gh, the running processes — or it degrades quietly to a smaller cezar. .ai/cezar/config.json is optional and every key has a working default; .env is never auto-loaded.

New state may be written, never required: .ai/cezar/, ~/.cache/cez/, ~/.cezar/. Delete any of them and cezar rebuilds what it needs on the next run. State that a user must author, migrate, or repair is not state — it is configuration, and it needs a reason. One deliberate exception inside that rule: the project registry keeps a ~/.cezar/config.json.bak snapshot, so deleting config.json alone restores instead of resetting — delete the ~/.cezar directory for a true reset.

Practical rules:

  • When a feature seems to need configuration, the design is wrong. Discover it, or default it.
  • Features that widen exposure or cost (network, other processes) are opt-in behind a CEZ_* flag, off by default — the zero-config default is also the safe default.
  • Owner-approved exception: Open Mercato skill updates are default-on. They start only after the server listens, run at most once per six-hour cache window, use explicit lock-authorized names and fixed bounded npx argument arrays under a cross-process lock, and never block boot. CEZ_SKILLS_AUTO_UPDATE=0 or the global Settings override disables automatic application (background detection remains read-only).
  • A missing dependency, an absent peer, a read-only home: degrade to a smaller working cockpit, never fail the boot.
  • Prefer a proxy-free, daemon-free mechanism when one exists — and when it doesn't, keep the mechanism invisible: no process to manage, no port to remember, no file to edit.
  • Never trade a working default for a knob.
  • Adding, renaming, or removing a CEZ_* env var — or changing what its default does — MUST update .env.example in the same commit (and the README env table when the var is user-facing). .env.example is the env contract's single documentation surface; an undocumented env var is a bug.

Changing a mechanism that already works

Replacing working behavior is the highest-risk change in this repo, and it fails in a characteristic way: the new mechanism is correct, the tests are green, the spec is thorough — and the DEFAULT path quietly lost a guarantee nobody wrote down. #810 and #811 are the worked examples; both shipped a well-specified improvement that left the zero-config user worse off than before.

Name what the old mechanism was load-bearing FOR, not what it was for. Those are different questions. IDLE_TIMEOUT_MS read as session hygiene, and #661 removed it from the monitoring branch for a good reason (it was closing live sessions mid-CI and recording them as done). It was also the only liveness bound on CEZ:MONITORING and the only reason a parked monitor eventually stopped holding a maxParallel slot. Neither dependency was named in the spec, so neither was replaced, and monitoring became a state with no exit. Before deleting a timer, lock, cap or timeout, grep for everything that reaches a terminal state because of it.

A replacement that ships OFF is not a replacement. Diff the default path, not the feature. The question is always: with every new knob at its shipped default, what does the old scenario do now? If the answer is "nothing", the change removed a mechanism and added a setting. A spec's "Resolved assumptions" table answering a COST question with "opt-in, default null" is not an answer to whether the zero-config path still works — cost-safe and functional are separate reviews. See § Zero config: never trade a working default for a knob.

Enumerate the transitions out of every state you add or keep. "Who fires this?" is the question that finds these bugs in one step. A parked run has exactly three wake sources — a user message (deliverMessage), the autonomous nudge (turn-end only), and the monitoring wake timer. Cezar has no process-exit callback, no CI webhook and no sub-agent-completion event, so a state whose only on-by-default exit is "a human types something" is a dead end, however well it renders.

Find every construction site of a shared in-memory object — grep the TYPE, not the field. ActiveRun is built in execute AND in runContinuation; #811 populated state.skills in the first only, so registry /skill expansion worked on new tasks and silently failed on every Continue and every restart recovery. The same shape recurs in the two near-identical turn-end handlers in workflows/run.ts (streaming and non-streaming): a lifecycle change applied to one of them ships half a fix. When you add a field that a delivery path reads, add it everywhere in the same commit or route both sites through one helper.

A fail-open helper needs a populated-input guarantee, or it lies. expandRegistrySlashSkill returning its input unchanged on no-match is right on its own — a backend's own slash commands must survive. Against an empty registry that same branch turns "cezar never loaded the list" into a confident user-facing "Unknown skill". Pair every silent pass-through with a test that pins the empty/absent input case.

Prove the regression test fails without the fix. git stash push -- <source files>, run the new test, confirm red, git stash pop. A test written after the diagnosis passes against the bug more often than anyone expects, and a green-either-way test is how the same regression ships twice. Keep the guard tests that pass both ways — they pin the behavior you did NOT want to change (park mode stays reachable; unknown slashes stay untouched) — but know which is which.

Read the run-history evidence before theorizing, and cite it. .ai/specs/ records the design, git log -S and git merge-base --is-ancestor <commit> <tag> settle "was this in the release the user is on", and a user's "it worked in 0.9.1" is a testable claim, not an opinion. #810 was confirmed in one command before a line of code was read.

The HTTP API

Four invariants. A feature that breaks any of them compiles on its own branch and stops working when it lands — #694 arrived with eleven unreachable routes for exactly this reason.

  • Every request and response shape is a zod schema in packages/contract, with its TypeScript type inferred from it (z.infer). Never hand-write an API type, and never declare one in server.ts or in the api-client. The api-client re-exports the contract; the cockpit imports the schema when it wants to validate and the type when it wants to compile.
  • Register routes by CHAINING them into a family builder, the way the ~24 families in server.ts already do. Hono accumulates route types through the chain only: a loose app.get(…) statement returns a value nobody keeps, so the route vanishes from AppType and the typed client cannot see it — silently, with the server still serving it.
  • Validate bodies, path params and the query string as route MIDDLEWARE, through the trio in src/server/validators.ts. Parsing inside a handler is invisible to hono, which is what let POST /runs accept { totalNonsense: 12345 } from a typed client without complaint.
  • Everything answers under /api/v1 (project-scoped: /api/v1/p/:projectId/…). The unversioned surface is gone.

The contract must describe EXACTLY what the route sends — no wider, no narrower. When a schema and its route disagree, fix the SOURCE, never widen the schema; contract-parity*.test.ts asserts both directions and a one-way check passes on real drift. Two mismatches recur often enough to name: writing key: maybeUndefined types a key as always-present that JSON.stringify drops from the wire (spread it conditionally), and an object-literal type: 'x' widens to string during hono's inference, erasing a discriminant consumers narrow on (as const).

Repository layout

Four npm workspaces under a private root that publishes nothing itself:

Path Package What it is
packages/cezar @open-mercato/cezar The service + CLI, and everything behind them (runs, workflows, agent runners, workspace state). The published artifact: bin, plus the built cockpit in web/dist.
packages/contract @open-mercato/cezar-contract The HTTP contract itself: every request and response as a zod schema with its TypeScript type inferred from it, so no shape is written twice. Node-free on the same terms as the api-client (lib: ["ES2022"], types: [] in its tsconfig make a node:* import a compile error). private, and that has a cost: the service imports a contract VALUE (workspaceUiStateSchema, in workspace/migrations.ts), so a published tarball naming a package npm has never seen would fail on install. packages/cezar/scripts/inline-contract.mjs runs as postbuild to fold the contract into dist/contract/ — bundle AND declarations — and repoint the emitted references. Delete that script the day the contract is published, or the day nothing in the service imports a contract value.
packages/api-client @open-mercato/cezar-api-client The contract between the two: the typed client over AppType, the SSE/protocol types, and the scope helpers. Re-exports packages/contract so a consumer needs one import. Node-free by construction — no node:*, no @types/node — because it is bundled into a browser AND imported by the Node service. private for now: versioned with the release but not on npm. The service may therefore only import it in TESTS — a runtime import would make the published CLI depend on something npm cannot resolve.
packages/web @open-mercato/cezar-web The cockpit SPA. Private; its output is an artifact of the service (vite build writes into packages/cezar/web/dist, which the CLI ships and serves).

Rules that follow from that:

  • The CLI is not a separate package and should not become one. It is the same program as the service — packages/cezar/src/index.ts boots startServer, but also RunManager, the workspace registry and the worktree machinery, and cezar run executes a workflow with no server at all.
  • A dependency belongs to the workspace that imports it. The root carries only what spans all four (typescript, vitest, tsx). A build script counts as an importer: scripts/inline-contract.mjs reaches for esbuild, so esbuild is a devDependency of packages/cezar rather than something inherited from whatever vite happens to hoist.
  • Cross-package imports go through the package name, never a relative path — the exceptions are test-only reaches for golden fixtures, and they are ugly on purpose.
  • The repo root keeps only what spans workspaces: scripts/dev.mjs (boots both halves) and scripts/release*.mjs (a release spans every package). Everything else lives in the package that owns it.

Task routing

When the task involves… Read first Key rules
CLI entry, serve/run/init subcommands, flags packages/cezar/src/index.ts Uses node:util parseArgs, no CLI framework. serve is the default command. Headless run treats review as a terminal success status (exit 0). init never overwrites existing files. Keep .ai/cezar/.gitignore maintenance (ensureDataGitignore) in sync with any new state file.
Agent runners / backends AGENT_PROTOCOL.md (the contract), then packages/cezar/src/core/agent-runner.ts, packages/cezar/src/core/runner-factory.ts, packages/cezar/src/core/claude-cli-runner.ts, packages/cezar/src/core/codex-app-server-runner.ts, packages/cezar/src/core/opencode-server-runner.ts, packages/cezar/src/core/backend-detect.ts One seam: every backend implements AgentRunner/AgentSession and emits both the v1 AgentEvent stream and the normalized v2 UiEvent protocol. Read AGENT_PROTOCOL.md first — it is the contract a new runner must satisfy (the event schema, per-backend mapping, golden-fixture testing, and the backend-parity requirement), with an explicit new-runner checklist. New backends slot in as one class — do not leak backend-specific types past the seam. claude-cli is a legacy backend id kept so old run records parse. CEZ_DRY_RUN=1 must keep working (bundled mock, no real CLI). Tool access goes through allowedTools, but the zero-config default includes unrestricted Bash (no bashAllowlist) and Codex/OpenCode don't honor allowedTools at all — see #430.
HTTP server & API routes packages/cezar/src/server/server.ts Hono app, binds to 127.0.0.1 only. A global /api/* request-origin guard (#426) runs before every route except /api/v1/health: it rejects non-loopback Host headers (DNS-rebinding) in local mode and cross-origin mutating requests (CSRF) in both modes — zero-config, no token, so the same-origin cockpit and the Vite dev proxy pass untouched. The loopback test is isLoopbackHostHeader (anchored 127.0.0.0/8, missing Host = untrusted); never match it with a 127. string prefix. Request validation is route MIDDLEWARE, not a safeParse inside the handlerjsonZodValidator / paramZodValidator / queryZodValidator from packages/cezar/src/server/validators.ts, read from `c.req.valid('json'
API request/response shapes (any new field, route or payload) packages/contract/src/*.ts, then packages/cezar/src/server/validators.ts One zod definition per shape, type inferred — never a hand-written interface. Add the schema here FIRST, then chain the route and validate through the trio; contract-parity*.test.ts proves the schema and the route agree in both directions, and typed-bodies.test.ts proves the route reached AppType at all. See the HTTP API section above for why a loose app.get(…) disappears.
Real-time events (live UI signals, replacing polls) .ai/specs/2026-07-23-websocket-subscriptions.md, then packages/cezar/src/server/ws.ts + the health topic in packages/cezar/src/server/server.ts, and packages/web/src/api/ws.ts Two live channels: the SSE run/event stream (/api/v1/workspace/events, packages/web/src/api/global-events.tsx) and the WebSocket subscription bus (/api/v1/ws, ws lib server-side, native WebSocket client). The bus is the pattern for any new live signal instead of a refetchInterval. Its whole discipline is demand-driven subscription: a topic's server publisher starts on the 0→1 subscriber and stops on 1→0, and one socket per cockpit ref-counts listeners per topic. Subscribe at the scope that matches the data's demand lifetime, always return the unsubscribe (useEffect(() => subscribeTopic(...))), publish only on change — a leaked subscription is a server publisher that never stops AND a component that keeps waking for a screen the user left. Per-view signals subscribe in the view; session-global signals subscribe ONCE at the root, not per reader — health is the worked example: one useHealthSubscription() controller in GlobalEventsProvider subscribes after bootstrap only for local mode, while useHealth() is a pure cache read the ~15 readers call without touching the socket. Remote mode must open no browser WebSocket because it cannot explicitly carry reverse-proxy credentials; it uses authenticated HTTP plus SSE reconciliation. One socket per local cockpit — never new WebSocket for a feature; add a topic. Liveness: server ping/pong reaping + app-level beat, client watchdog + reconnect. Topics are workspace-level (single-mount, never /api/v1/p/) and, because the upgrade guard admits any loopback origin (WS has no CORS), must only carry data safe for any local page — read the spec's security caveat before adding one.
Workspace registry / per-user state (~/.cezar/) packages/cezar/src/paths.ts, then packages/cezar/src/workspace/config.ts, packages/cezar/src/workspace/projects.ts, packages/cezar/src/workspace/migrations.ts, packages/cezar/src/workspace/semaphore.ts ~/.cezar/config.json is the per-user workspace config and the project registry — every repo cezar has been booted in (spec 2026-07-20-multi-project-workspace). Every path goes through cezarHomeDir() so CEZ_HOME keeps tests and containers off a real home; never re-derive homedir() elsewhere. Schema rules are load-bearing: every field optional with .catch, .passthrough() at every object level, per-entry salvage for projects (one bad row never evicts the registry), and writes only through mergeWriteWorkspaceConfig (read-modify-write + atomic tmp/rename, 0600) so two processes converge. A corrupt or read-only home degrades to in-memory defaults with ONE warning — never a boot failure. Migrations are config-files-only, idempotent, additive and non-blocking; run state never migrates. maxParallel/memoryLimitMb are workspace-wide (resources): the per-repo keys are imported once by migration 001 and ignored by enforcement afterwards. Registration is suppressed for task worktrees and $HOME itself (shouldRegisterProject). A merge-write resolves its path ONCE and uses it for both halves — resolving it twice let a CEZ_HOME that changed mid-flight read one home and write another, wiping the user's registry. Two guards back that up: assertCezarHomeWriteIsSandboxed refuses any write into the real ~/.cezar from a vitest process, and packages/cezar/vitest.setup.ts pins CEZ_HOME to a per-worker sandbox so no case ever runs unpinned. Every write that leaves projects behind also snapshots config.json.bak, which loadWorkspaceConfig restores from when the config is missing, empty or corrupt (a config that parses and is simply empty is the user's own state and is never overridden).
Project-scoped routes & contexts (/api/v1/p/:projectId, /p/:projectId) packages/cezar/src/server/project-context.ts, then packages/cezar/src/server/server.ts, packages/web/src/routes.tsx One lazily built {store, manager, dataDir, launchKey} context per registered project, disposed on removal; a missing root is never instantiated (unknown id → 404, gone root → 409). Every project route is registered once in a chained family builder and mounted under both /api/v1/<path> (bound to the boot project) and /api/v1/p/:projectId/<path>route-parity.test.ts walks the exported route manifest and asserts /api/v1/x, /api/v1/p/<boot>/x and /api/v1/p/default/x answer byte-identically, so a new scoped route without its alias fails the suite. default is the reserved boot alias and is never an allocated slug. Workspace-level routes (/projects, /workspace/*, /fs/browse) are single-mount and never scope-prefixed. The cockpit mirrors the split: every view lives under /p/:projectId/, flat legacy PAGE paths redirect to the boot project, and global settings sit outside the scope at /settings/global. One versioned API surface: the unversioned /api/* spelling was removed — everything answers under /api/v1. A chained builder is the only shape Hono can infer route types from, which is what makes AppType (packages/cezar/src/server/app-type.ts) and the typed client cover the API. Add a route by adding a link to its family's chain, never a loose api.get(…) statement — a statement reaches neither the typed client nor AppType; versioned-surface.test.ts fails on any route outside /api/v1, and bc-route-inventory.test.ts (which reads a built app's route table, not the source) requires it to be inventoried in BACKWARD_COMPATIBILITY.md §2.
Git / worktree logic packages/cezar/src/git-worktree.ts, packages/cezar/src/git-diff-base.ts, packages/cezar/src/server/git.ts One worktree per task at .ai/cezar/worktrees/<runId>, branch cez/<id8> off the configured base branch. Helpers never throw (except createWorktree) — degradation is the caller's policy. Diffs are capped (DIFF_CAP). Orphaned worktrees are pruned at startup. "Which ref anchors this task's diff" lives in exactly one placeresolveTaskDiffBase (git-diff-base.ts): the merge-base against the freshest base ref (origin/<base> when the local branch is behind it — agents fetch, they never pull, and a stale local main is what turned an eight-line fix into +59514 −12160), and, when the agent checked another branch out into the worktree, that branch as the run found it (<branch>@{<run start>}), whichever of the two attributes fewer changed lines. Attributing a checked-out branch's history to this task is what produced five-figure numbers on review and QA runs (#591 fixed the Changes tab, #751 the stored diffStat); anchoring those runs at HEAD instead then reported +0 −0 for work they really did commit. A new task-diff surface resolves through that helper and passes the run's branch and startedAt; worktreeDiff/worktreeDiffStat deliberately keep the whole-branch anchor and each carry a comment saying why.
GitHub integration (issues/PRs tab, draft PRs) packages/cezar/src/server/github.ts, packages/cezar/src/server/pr.ts Must degrade gracefully: no gh, no remote, offline all return { available: false, reason } — never an error. gh … --json output is zod-validated at the boundary. GITHUB_TOKEN is the fallback when gh isn't authenticated. createDraftPr never throws; failures map to one-line human errors. CEZ_DRY_RUN=1 fakes the PR URL.
Workflows (YAML chains, steps, retries) packages/cezar/src/workflows/types.ts, then packages/cezar/src/workflows/load.ts, packages/cezar/src/workflows/run.ts A step is agent (prompt/skill) XOR check (command); a file has steps XOR the portable skills shorthand — both enforced by zod refinements. onFail.retry may only reference an earlier step (stepsIssue). {{task}} is the substitution token. quick-task is the built-in zero-config workflow; built-ins always come back after delete.
Skills (Markdown playbooks, team repos) packages/cezar/src/skills.ts, packages/cezar/src/skills-remote.ts A skill is a .md file with optional YAML frontmatter (name, description); its body becomes the agent's extra system prompt. Discovery precedence is local-first: .ai/cezar/skills.ai/skills.agents/skills + agent mirrors → global → team repo. Missing dirs are fine; team-skill loading never blocks on the network (background cache in ~/.cache/cez/).
Runs store / state persistence packages/cezar/src/runs/store.ts Plain files in .ai/cezar/, no DB: runs.json index (zod-validated, atomic tmp+rename, debounced save) plus one append-only NDJSON event file per run. New RunRecord fields must be optional so old files still parse; corrupt files degrade to fresh, never crash. A queued run's prompt stays editable until the scheduler picks it up (#472): task plus the optional queuedMessages stack are folded into {{task}} at dequeue, and that fold is read-only — never write it back to the record. The store is also the in-process event bus for SSE.
Web UI (cockpit) packages/web/src/app.tsx, then packages/web/src/routes.tsx, packages/web/src/api/, and the affected component/route React 19 + Vite + Tailwind v4 + shadcn/ui; source lives in packages/web/ (its own npm workspace, @open-mercato/cezar-web, with the React/Vite/Tailwind deps in ITS manifest, not the root's), build output in ignored packages/cezar/web/dist/. Anything the server and the cockpit BOTH speak — the v2 protocol types, the /api/v1/p/:projectId scope helpers, and (as it grows) the typed API client — lives in the third workspace packages/api-client/ (@open-mercato/cezar-api-client), which must stay Node-free: no node:* import and no @types/node, because it is bundled into the browser AND imported by the Node service. Keep one global SSE connection and patch the TanStack Query cache in place; authoritative refetch happens on reconnect/visibility. Preserve light/dark/system theming, mobile safe areas, keyboard access, and unit coverage. The legacy vanilla web app was deleted in R7; when packages/cezar/web/dist is missing the server answers every shell route with a built-in "run npm run build:web" hint page (packages/cezar/src/server/static-ui.ts).
Agent config files (Settings → Agent config; grouped by agent, MCP as a per-agent subsection — spec 2026-07-17-agent-config-by-agent, descriptor table in packages/web/src/routes/settings/agent-descriptors.ts) packages/cezar/src/agent-config/ (catalog.ts, files.ts, validate.ts, service.ts, seed.ts), then packages/cezar/src/paths.ts and the /api/v1/agent-config routes in packages/cezar/src/server/server.ts Read and edit the coding agents' OWN config files (Claude/Codex/OpenCode settings, MCP, memory), raw and per-scope (spec #404). catalog.ts is the ONLY place vendor knowledge about config FILES lives — paths + verbatim precedence strings; keep it accurate and dated. Its sibling src/core/agent-profiles.ts owns the other half: the env var that relocates each agent's whole home (spec 2026-07-29-agent-profiles). Never re-serialize a file cezar opened (byte-exact round-trip). Files are addressed by catalog id, never a path. Writing is a local-machine capability: every PUT /api/agent-config/:id 409s when capabilities().localHandoff is false — this closes a hooks-based RCE path, do not weaken it. The gitignored personal layer is seeded into run worktrees (seed.ts, guarded by git check-ignore).
Feature specs / design history .ai/specs/ Numbered and dated specs are the design record — code comments cite them (spec 006, #348). Read the relevant spec before changing a feature it covers; keep new work consistent with it or update the spec.

Validation

Before any commit or PR, run in order:

npm run typecheck   # tsc --noEmit (api-client + server + web)
npm test            # vitest — server + cockpit unit suites
npm run test:unit   # node:test — fast core-module coverage (packages/cezar/test/unit/)
npm run build       # tsc → dist/, vite → packages/cezar/web/dist/, then the check:pack tarball gate
npm run test:package # pack/install the release tarball and exercise the built CLI (packages/cezar/test/e2e/)

npm test and npm run test:unit are the fast unit gate: no server, no browser. They must stay that way. npm run test:package needs a completed npm run build (it packs the tarball).

Run vitest through npm, never npx vitest. It is a devDependency of this repo, so npm test uses the installed, version-pinned binary; npx will happily reach past it and fetch a different version from the registry, which is a slow, networked, silently-different test run. To narrow a run, pass vitest's own arguments after --:

npm test -- packages/web/src/routes/settings   # one directory
npm test -- --testTimeout=30000 path/to/one.test.ts
npm test -- -t "the name of one test"

The UI smoke suite is a separate command — it boots the real app and drives it in a real Chrome through the agent-browser provider (.ai/browsers/agent-browser.md):

npm run test:e2e    # .ai/scripts/e2e.sh → test-env-up.sh + vitest (packages/web/e2e/)

It boots the app on a free port with CEZ_DRY_RUN=1 (agent CLIs mocked — no login, no network), reuses an already-healthy instance instead of double-booting, and writes .ai/qa/test-env.json so QA skills attach to the same instance. Stop it with .ai/scripts/test-env-down.sh. Exit contract:

Exit Marker Meaning
0 TEST_E2E_STATUS=passed every spec passed
0 TEST_E2E_STATUS=skipped agent-browser could not be provisioned (no network / unsupported platform); prints a loud banner — not a pass
non-zero TEST_E2E_STATUS=failed a spec failed, or the env could not boot

CEZ_DRY_RUN=1 npm run dev still exercises the whole cockpit offline for manual verification.

Related documents

  • AGENT_PROTOCOL.md — the agent protocol: the runner seam, the v1 AgentEvent + v2 UiEvent streams, per-backend mapping, the golden-fixture testing contract, and the checklist for adding a new runner.
  • SDLC.md — ticket flow, label state machine, QA gate, claim protocol.
  • CODE_REVIEW.md — what reviewers check and how severities are assigned.
  • BACKWARD_COMPATIBILITY.md — the public surfaces you must not break silently.
  • .ai/agentic.config.json — machine-readable pipeline config every om-* skill reads (base branch, validation commands, labels).