Architecture¶
Reviewed against the source on 2026-08-03 for
0.2.3. Where this document and the code disagree, the code wins and the disagreement is a bug in this file — the documentation audit lists the ones found so far.
System overview¶
keryx is a single-binary Bun/TypeScript command-line tool (ESM). It has no
database and nothing running by default. Its one job is to scaffold and
maintain a per-project .metaproject/ workspace — a file-based "agent
operating system" that makes an AI coding agent more effective and more
governable inside an existing repository.
Two HTTP surfaces do exist, and both are opt-in and off until you start
them: keryx mcp serve --http (localhost-only, additionally gated on
http.enabled in the module manifest) and keryx serve, the loopback-bound
remote entry over the agent harness. Neither runs unless configured and
launched. Earlier revisions of this document said "no HTTP server, and no
network service"; that stopped being true when remote entry shipped.
The core idea: instead of an agent re-deriving a project's structure, quality,
tests, conventions, and history from raw files on every task, keryx materializes
that knowledge as durable, human-editable Markdown plus machine-readable JSON
artifacts under .metaproject/, and installs routing rules so any agent that
reads the repo's AGENTS.md/CLAUDE.md is directed to consult that workspace
first.
The CLI itself performs only deterministic mechanics — scanning, graphing,
scoring, state transitions, checksums, template rendering. The "cognitive" work
is delegated to the bundled agent skills that the workspace ships. Everything is
local-first and offline by default: state lives on disk under
.metaproject/, no implicit network or global-runtime writes happen, and
external tools (git, gh, eslint, tsc) are optional.
Most things degrade rather than break when a dependency is absent — but not everything, and the exceptions are named in the README rather than glossed here.
Where keryx sits¶
flowchart LR
H["Human developer"]
A["Agent runtimes<br/>Claude · Codex · Cursor · Windsurf · OpenCode"]
R[("Your repository")]
subgraph K["keryx"]
direction TB
W["`.metaproject/` workspace<br/>graph · wiki · memory · health · skills · SAC"]
S["Agent harness<br/>policy · session · tools"]
end
H -->|"CLI"| K
A -->|"reads AGENTS.md / CLAUDE.md<br/>→ routed to `.metaproject/index.md`"| W
A -->|"MCP (opt-in)"| W
K -->|"scans, never mutates without a decision"| R
W -.->|"committed alongside the code"| R
S -->|"loopback HTTP, off by default"| E["`keryx serve` clients<br/>bot · browser workspace"]
Everything inside the box is files in the repository. That is the whole positioning: the project's context is versioned with the project, readable by a human in a diff and by an agent through one index, with nothing hosted.
Layered architecture¶
The whole codebase follows one consistent four-layer pattern:
src/cli.ts Layer 1 — Dispatch
│ CLI_ROUTES table (~31 verbs) → <name>Command(args.slice(1))
▼
src/commands/<name>.ts Layer 2 — Command handlers
│ parse flags, print output, set process.exitCode; NO domain logic
▼
src/<feature>/service.ts (+ *.ts) Layer 3 — Feature/service (domain logic)
│ pure-ish async functions keyed on an explicit `cwd` project root
▼
src/lib/* Layer 4 — Shared toolkit
args · fs · json · prompt · ui · templates (+ external tool adapters)
── opt-in substrate (cross-cutting, roadmap-2026) ────────────────────────
src/capability/ the seam every opt-in layer instantiates (resolve-or-null)
src/assets/ local-only, sha256-verified asset resolution (+ pull path)
src/eval/ fixture-corpus precision/recall acceptance gates
src/mcp/ stdio-first protocol surface over the service facades
- Layer 1 (
cli.ts) is a thin dispatcher over an exportedCLI_ROUTEStable (src/cli.ts:51) of roughly 31 verbs, each awaiting its handler. That table — not the hand-writtenprintHelp, which has drifted — is the honest surface;keryx commands --jsonprojects it. Top-level errors are caught only underimport.meta.mainand mapped toexitCode = 1. Handlers setprocess.exitCodeand never callprocess.exit. - Layer 2 (
commands/*.ts) handlers are deliberately thin: ad-hoc flag parsing, aswitch/if-chain over the subcommand, a call into the feature service, then console/JSON rendering. They ownprocess.exitCode. - Layer 3 (
<feature>/) holds domain logic as stateless free functions over acwdargument (no classes, no DI container). Some modules add an optional service-facade object (createGdWikiService,createCodeHealthService,createMemoryService) for DI/testing symmetry;flowuniquely uses constructor dependency injection (tracker,healthGate,now) for a genuinely testable state machine. Presentation strings live in per-moduletemplates.ts; the type surface intypes.ts. - Layer 4 (
src/lib/) is the bottom layer everything imports (fsalone has 32 importers). It depends on nothing else in the project.
Two invariants¶
Two invariants define the system and recur across every module:
-
Idempotent reconciler. Managed files are written through
writeTextIfMissing(seed-once, never clobber user edits),writeTextIfChanged/writeJsonIfChanged(always reconcile to the rendered template), andcopyFileIfChanged. Managed regions inside otherwise user-owned files use sentinel blocks (# keryx:<id>:begin…:end,<!-- keryx:index -->,<!-- WIKI_INDEX_BEGIN/END -->,<!-- gdskills:project-skills:start/end -->) so regeneration preserves surrounding human prose. This makesinit/updatesafe to re-run any number of times. -
Data-vs-service separation.
.metaproject/splits into service files (templates, manifests, skills, hooks, dashboard, config — regenerated byinit/update) and.metaproject/data/**artifacts (module run outputs — NEVER written by the lifecycle commands). Every feature module writes its generated outputs underdata/; the lifecycle layer treatsdata/as read-only. This is what lets self-update refresh the toolchain without destroying accumulated project knowledge.
Module map¶
| Module | Directory | CLI verb | Role |
|---|---|---|---|
| cli-core | src/cli.ts, src/commands/* |
init / update / status / dashboard (dash alias) / modules / standard / agents |
Argv dispatch and lifecycle: scaffold, refresh, report, and visualize the workspace (dash opens the built dashboard); toggle modules (modules); validate Metaproject Standard compliance (standard); dispatch global agent bootstrap (agents); owns MODULE_COMMANDS. |
| shared-lib | src/lib/ |
— | Bottom-layer toolkit: args, fs, json, prompt, ui, and the templates.ts code-gen that renders the whole .metaproject/ scaffold. |
| rules | src/rules/ |
rules |
Sync/distill root AGENTS.md/CLAUDE.md into high-priority workspace rules and inject the managed Metaproject routing block. |
| agents | src/agents/, src/commands/agents.ts |
agents bootstrap |
Install/uninstall/status a managed routing block in global per-runtime agent entrypoints (~/…/CLAUDE.md/AGENTS.md) via agents bootstrap status\|install\|uninstall --runtime claude\|opencode\|zcode\|codex\|antigravity\|all — the global counterpart to rules' repo-root injection. |
| gdgraph | src/gdgraph/ |
gdgraph |
Build the deterministic file graph and optional tree-sitter symbol/call graph; find files or symbols, inspect callers and impact, compute paths/affected sets, query cycles/orphans, and render a bounded repomap. |
| gdctx | src/commands/ctx.ts, src/ctx/ |
ctx |
Token-saving command/search/read wrapper plus merge-safe routing guards for supported agent runtimes. |
| gdwiki | src/wiki/ |
wiki |
File-based project knowledge base with hierarchical full-coverage collection, link validation, code/wiki backlinks, bounded context, and grounded retrieval. |
| gdskills | src/gdskills/ |
skills |
Manage bundled and project agent skills: install, route, verify, learn, export/sync to runtimes; owns JSON interop contracts. |
| health | src/health/ |
health |
Aggregate code-quality signals into per-scope scores, compare to baseline, and evaluate a pass/warn/fail gate; rank churn×complexity hotspots (rankHotspots) which feed the opt-in D1 hotspot scoring penalty. |
| testing | src/testing/ |
test |
Detect the test stack, run the project's existing runner (optionally changed-scoped), normalize results into a report. |
| memory | src/memory/ |
memory |
Long-term typed project memory (lessons/decisions/constraints); deterministic search, dedup, ingest, reflect. |
| flow | src/flow/ |
flow (manifest id tasks) |
Agent-first work lifecycle: scaffold a "flow" package, drive a status state machine, enforce completion gates. |
| review | src/review/, src/commands/review.ts |
review |
Managed review packages: standalone, flow-attached, or report-ingested review state with explicit coverage, findings, decisions, learning handoff, and completion validation. |
| security | src/security/ |
security |
Agent input/output + artifact security: deterministic secret/PII/injection/egress detectors, policy resolution, redaction, and a pass/needs-approval/fail gate (status/scan/check-input/check-output/redact/report/policy); MCP-manifest scanning (scan-mcp); merge-safe agent security-hook install (hooks); labeled-corpus FN-rate evaluation (eval, optionally --with-model); incident log (incidents); plus a config-checksum self-protect tamper guard. |
| orientation | src/ctx/orient.ts, src/ctx/orient-runtimes.ts, src/commands/orient.ts |
orient |
Build a bounded project-root Metaproject excerpt + graph + wiki startup context and install/remove compatible turn-start hooks for Claude, Codex, and Cursor. |
| capability | src/capability/ |
— (substrate) | The opt-in seam every feature layer instantiates: resolveCapability(cwd, spec) gates on manifest + optional dep + verified asset, returns an adapter or null. Never throws. |
| assets | src/assets/ |
assets (per module) |
Local-only, sha256-verified asset resolution (resolveAsset); pullAsset is the sole network path (verify-or-refuse); assets.lock.json pins provenance; assets list\|verify\|pull. |
| eval | src/eval/ |
— (test-time) | Fixture-corpus acceptance harness: runCorpus/gateCorpus produce deterministic precision/recall/FN-rate reports used as CI gates by multiple opt-in blocks. |
| mcp | src/mcp/ |
mcp |
Thin stdio-first Model Context Protocol surface over the createXService() facades: SDK-free dispatch core, Tool registry (including mutating sac.propose / sac.review, HTTP-denied), read-only metaproject:// Resources, single redaction choke point. |
| sac | src/sac/ |
workspace |
Shared Agent Context: file-backed workspace registry, FWK overview/read, propose/review via guarded wiki/memory/skill writers, hash-chained access receipts. Not a default init module. See the operator guide. |
| harness | src/harness/ | harness run\|exec\|extension\|wave\|replay | The agent execution loop: session, policy engine, tool registry, provider port, resume, branching, compaction, guarded mutation, child agents, parallel scheduling, extensions, budget, replay-fixture validation, and (opt-in, off by default) the external child runtime that hosts a vendor coding CLI. See "The agent harness" above, and the feature-level tour. |
| sandbox | src/harness/process/sandbox/ | — (via harness exec) | OS-enforced containment: Seatbelt and bubblewrap launchers, the loopback allowlist proxy, the ephemeral run CA, credential masking. Two capability tiers with a hard platform split. |
| tui | src/tui/, src/commands/shell.ts | shell | The OpenTUI full-screen shell, default when stdout is a TTY, with a readline fallback. One core with three renderers, not three shells. Slash inspectors: /status, /flows. |
| session | src/session/, src/commands/sessions.ts | sessions | Per-project append-only agent sessions: list, fork (branch with parentSessionId ancestry), export, locate. |
| serve | src/commands/serve.ts, src/lib/serve-*.ts | serve | Loopback-bound HTTP entry over the harness. Not a module in the manifest sense — a command. |
| projects | src/lib/project-registry.ts | projects | The user-global project registry that remote entry addresses projects by. |
| metrics | src/metrics/ | metrics | Provenance-aware execution observability: run records, active-time accounting, baseline comparison, benchmark manifests. |
| contracts | src/contracts/ | — (substrate) | A dependency-free JSON Schema validator covering the whole used-keyword set, with cross-file and local $ref/$defs. |
Module, or command? A module has a manifest entry, a manifest file and a
src/<feature> behind a verb. Nine are enabled by init; mcp is a tenth,
real but off by default. review, serve, orient and sync are commands
— no manifest entry, not toggleable by keryx modules. Conflating the two is a
recurring documentation error, including in earlier revisions of this file.
Two live caveats about keryx modules itself: security is enabled by default
but absent from that command's module list, so it cannot be toggled there; and
toggling anything currently drops an enabled mcp from the manifest.
The product modules are joined by cross-cutting command surfaces (agents,
orient, and review), three opt-in substrates (capability, assets, and
harness), and the MCP protocol adapter. See "Capability System — opt-in
layers" below.
Capability System — opt-in layers¶
The roadmap-2026 blocks add smarter, heavier behaviors (tree-sitter symbol graphs, embeddings, model-based threat/PII detection, coverage maps, an MCP server) without compromising the local-first, offline, deterministic core. They do this through a single architectural substrate — the Capability Seam — governed by one cross-cutting invariant: the golden rule.
The golden rule (determinism invariant)¶
With zero opt-in flags and no assets present, every command and the full test suite behave byte-identically to the deterministic core — no optional dependency is loaded, and no socket is opened.
This is the third system invariant (alongside the idempotent reconciler and data-vs-service separation). It frames the whole design as a floor vs opt-in ceilings model: the deterministic algorithms (regex graphing, token/trigram memory search, pattern-based security, heuristic test parsing) are the always-present floor and the default fallback; every heavier capability is a ceiling that must be explicitly opted into and can never change the floor's output. A dedicated capability/golden-rule.test.ts asserts the invariant (including a no-socket check); no-optional-imports.test.ts and mcp/no-network.test.ts guard the "no top-level optional import / no network" halves.
The Capability Seam (src/capability/)¶
resolveCapability(cwd, spec: CapabilitySpec) → CapabilityAdapter | null is the sanctioned way to reach any opt-in behavior. It gates, in order, on:
- Manifest-enabled — the capability id appears as an enriched
{ id, enabled: true }entry in somemodules.<m>.capabilities[]ofmetaproject.json(a bare-string entry is an advertised floor, never an enabled ceiling; a missing manifest = off). - Optional dependency importable — loaded lazily via
await import(spec.optionalDependency)inside the seam only (the sole place an optional dep is loaded). - Asset resolved + sha256-verified — via the Asset Resolver (below).
- Adapter available — the built adapter's
isAvailable()probe returns true.
The instant any gate fails, the seam returns null and the caller runs its deterministic fallback. It never throws: every gate is wrapped in try/catch, there is an outer backstop, and each failure on an enabled-but-unsatisfiable ceiling emits a process-scoped warn-once (a disabled ceiling — the normal default — is silent, loads no dep, and touches no asset). runCapabilityOrFallback(adapter, input, fallback) is the sanctioned call pattern: it runs the adapter when present and catches any run() error, degrading to the fallback so an opt-in ceiling can never break a deterministic seam. The seam imports only shared libs + the Asset Resolver, keeping it acyclic (mirroring security/guard.ts). It generalizes the pre-existing security.backends opt-in idiom into one project-wide substrate that every Block A–E layer instantiates.
The shipped CAPABILITY_REGISTRY was intentionally empty until the external agent runtime; it now holds exactly one descriptor, gdskills.external-agents, which is where keryx init --external-agents / --no-external-agents comes from. Registering a descriptor is all a block does to gain that uniform --<cap>/--no-<cap> init/update wiring. The descriptor is a ceiling, so it defaults off and keryx update materialises a newly-registered one as enabled: false rather than silently switching it on. It declares no optional dependency, no asset and no module config: the external runtime's settings are user-global (externalAgents in ~/.local/share/keryx/auth.json), because a subscription belongs to a person rather than to a checkout, and a project-scoped copy would be a second place for the same setting to disagree with itself.
Dependency policy¶
package.json dependencies stays {}. The shipped opt-in runtimes are
web-tree-sitter and @modelcontextprotocol/sdk; both live only under
optionalDependencies and are loaded lazily on their explicit feature paths.
The former @xenova/transformers runtime is no longer shipped. Memory and
security retain deterministic fallbacks and can use an explicitly configured
compatible adapter without adding a default runtime dependency. Static import
boundary tests keep optional packages out of the deterministic startup path.
Asset Resolver (src/assets/)¶
resolveAsset(registry, id) reads local files only and re-verifies sha256 on every load, returning null on a missing or tampered asset (⇒ deterministic fallback); it never opens a socket. pullAsset(id, lock) is the sole network path in keryx: it fetches the pinned url, verifies the download's sha256, and refuses on mismatch — writing nothing before the check passes. .metaproject/assets.lock.json is the committed provenance record, pinning { version, url, sha256, size } per asset id. A single assets list | verify [<id>] | pull <id> subcommand (delegated to by every opt-in module) is the only asset-management surface.
Fixture-corpus harness (src/eval/)¶
runCorpus(dir, detect) runs a block's detector over a committed fixtures/<corpus>/cases.json of labeled cases and computes a deterministic CorpusReport (precision, recall, false-negative rate; cases sorted by id so re-runs diff empty). gateCorpus(report, { maxFnRate }) turns that into a pass/fail CI gate. This generalizes "prove quality against labeled data, not prose" into a shared, per-block-code-free runner used as an acceptance gate by multiple blocks (mcp-threat, symbol-graph, temporal, paraphrase, churn-complexity, and the injection/exfil/PII corpora).
MCP surface (src/mcp/, Block A)¶
A thin, stdio-first Model Context Protocol adapter that exposes existing capabilities without inventing new domain logic:
- A pure, SDK-free dispatch core (
dispatch.ts) drivable directly in-process by unit tests (the parity gate) and by the real server. - A Tool registry (
tools.ts) where each tool is a thin adapter over exactly onecreateXService()facade method;src/mcp/imports only service facades + shared libs + the redaction guard — never a module's internals (an import-boundary test enforces this). - Read-only
metaproject://Resources (resources.ts) over configured roots. - A single
redactRawchoke point (redact-seam.ts): every tool result passes through the security engine's redaction before it reaches a transport; disabled security ⇒ byte-identical, any error ⇒ original content (never blocks a response). - The MCP SDK is lazy-loaded only in
server.tsand only on themcp servepath (the one sanctioned opt-in command allowed to hard-fail with an actionable message when its optional dep is absent, rather than degrade). The stdio transport is the default; an isolated HTTP/SSE transport is a separate, capability-gated opt-in.
Per-module opt-in layers¶
Each feature module keeps its deterministic algorithm as the default+fallback and adds a Capability-Seam-mediated ceiling:
- gdgraph — tree-sitter symbol graph (
src/gdgraph/treesitter/,web-tree-sitter) over the default regex import graph. The repomap/PageRank god-node surface (src/gdgraph/pagerank.ts,src/gdgraph/repomap.ts) rides on top: a pure, deterministic personalized-PageRank centrality ranks the most-depended files (the "god nodes") and renders a token-budgetedrepomap.md; it ranks symbols when the tree-sitter ceiling resolves and falls back to file-level ranking on the regex floor. - memory — pure canonical-Markdown lexical recall with optional embedding rerank through an explicitly configured transformer-compatible adapter; no embedding runtime is shipped. Catalogs, embeddings, and explicit reports are disposable generated views and are never required for recall. Memory also carries a bitemporal/temporal model (
src/memory/types.tsvalidFrom/validTo/recordedAtevent- vs ingestion-time fields), explicit validated lifecycle transitions, and a non-destructivesupersedewrite that closes an old entry's valid-window while keeping both Markdown files on disk, so history stays git-diffable and queryableasOfa date. - security — Prompt Guard 2 injection detection and NER-based PII (
src/security/detect/injection/,.../pii/ner-adapter.ts) over the default pattern detectors — the originalsecurity.backendsidiom, now expressed through the shared seam. - testing — a coverage-map capability (
src/testing/coverage-map.ts,src/testing/capability.ts) over the default heuristic report.
In every case the seam resolving to null (flag off, dep absent, or asset missing/tampered) transparently restores the deterministic floor.
The agent harness — and the two tool systems¶
src/harness/ (~171 files across ~20 subdirectories) is the execution loop that
lets a model operate on a project through controlled tools. It is
ports-and-adapters over 34 frozen JSON Schemas: every decision core is pure with
clock and idSeq injected, and the only effect surfaces are injected adapters
(MutationAdapter, ProcessAdapter, ProviderPort, ToolExecutorPort).
Read this before writing anything about tools¶
There are two tool systems, and a sentence that merges them is false in both directions.
| Durable tool system | Interactive tool system | |
|---|---|---|
| Types | ToolRegistry / ToolExecutorPort (src/harness/tool/) |
InteractiveTool (src/commands/agent.ts) |
| Returns | an outputHash — so it structurally cannot feed content back to a live model |
content |
| Used by | the schema-bound runOffline contract loop |
the shell that people actually run |
| Approval | mutation/approval.ts:checkApproval, reached only from keryx harness extension |
a y/N prompt in src/commands/agent.ts |
tool/metaproject-operations.ts is the only bridge, projecting one descriptor
into both.
And no shipped path registers a tool. Both production executors are
refusals — src/commands/harness.ts:247 ("Release 0 CLI runs register no
tools") and src/lib/serve-turn.ts:313 ("Remote turns register no tools in this
slice"). keryx harness run and keryx serve are single text turns today.
So "the policy engine gates the tools your agent runs" mixes the first system's code with the second system's behaviour. Describe them separately, and cite separately.
One turn, and who owns each decision¶
flowchart TB
P["Prompt"] --> ST["startRun<br/><i>startup.ts:49</i>"]
ST -->|"disabled ⇒ nothing constructed"| X1(["refused"])
ST --> CTX["Bounded context manifest"]
CTX --> PR["ProviderPort<br/><i>fake · Anthropic · Ollama</i>"]
PR --> GA["guardAction<br/><i>mutation/guard.ts:304</i><br/>structural safety, BEFORE policy"]
GA --> D{"decide<br/><i>policy/engine.ts:169</i>"}
D -->|"deny — terminal,<br/>no approval flips it"| X2(["denied"])
D -->|"ask — headless ⇒ deny"| AP["checkApproval<br/><i>approval.ts:111</i><br/>bound to actionFingerprint"]
D -->|"allow"| EX
AP -->|"valid, single-use"| EX["Injected adapter<br/><i>the sole effect surface</i>"]
AP -->|"stale · expired · consumed"| X2
EX --> EV["Evidence + append-only session<br/><i>redacted, or not persisted at all</i>"]
EV --> CG["evaluateCompletion<br/><i>completion/gate.ts:111</i><br/>reports; never advances flow state"]
CG --> MFP["ManagedFlowPort.completeFromGate<br/><i>the ONLY route to FlowService.taskDone</i>"]
Every arrow above is a named function. The seam table in the harness analysis carries the full list with citations; the properties worth stating in prose are:
- The harness never writes
flow.json— three independent mechanisms. The policy engine denies aflow.jsontarget even with a matching approval (engine.ts:92-96, 189-197); the managed-flow port performs no filesystem write and imports only Task Manager types; andchild/spawn.tsaccepts noFlowServiceor filesystem handle, so nothing in the child path can reach a write structurally. - An approval authorizes one action. It is valid only when
grantedForFingerprint === ctx.actionFingerprint, and a single-use grant is terminal once consumed. - Hard denies are terminal. No approval, role, or interactivity flips one;
overrideis a frozenfalse. - Headless never silently allows. An
askwith no live approver becomesdeny. This is what makes a remote turn's recorded denial correct rather than incidental. - A transport cannot upgrade a decision. CLI and RPC both delegate to the
same
runOffline; framing carries data, the engine decides policy. - Nothing is persisted unscanned. A failed scan blocks persistence entirely and emits only a reason — no preview, no hash, no category.
- History is append-only. Entries are content-addressed and deep-frozen;
compaction adds a derived record and throws
EvidenceDeletionErrorif any prior entry would disappear.
A third child runtime: vendor CLIs (src/harness/external/)¶
A subagent-dispatch may carry an optional runtime block asking for a vendor
coding CLI — codex exec, claude -p — instead of the in-process loop.
spawn_subagent exposes exactly one structural seam for it (runExternal), and
that seam is invoked after spawnChild: admission, the shared budget ledger
and the depth/child caps have all already applied, so there is no second spawn
path, no second ledger and no second event stream. The vendor's event vocabulary
is folded onto one canonical ExternalEvent set, so the existing fleet folds
consume external children unchanged.
Three properties are structural rather than conventional:
- The only impure seams are a process spawn and a git worktree port. The
registry, all argv construction, all event parsing and all failure
classification are pure functions, which is what lets a complete run be
exercised offline against the recorded transcripts in
fixtures/external/. Nothing in this subsystem has been run against a real vendor process. - The gate resolves before configuration is read. A remote transport or a CI marker disables the capability regardless of configuration, and that check runs first so the property is structural rather than a consequence of branch order. Below it sit the user-global opt-in and the project manifest opt-in; every refusal on every layer returns a named reason, because a silent no-op would leave the operator reading an absence as a success.
- No credential is read, written, or forwarded. keryx never opens a vendor
token store — not even to test whether a login exists — so availability is
derived from
--versionand exit codes alone and carries three states, of whichnot-probedis a real one.ANTHROPIC_*,CLAUDECODE,CLAUDE_CONFIG_DIR,CODEX_HOMEand the wholeCLAUDE_CODE_*/KERYX_*namespaces are stripped from the child environment. No vendor sanction is claimed; the residual risk is recorded as a risk in the package'sdecisions.mdD-01.
Execution is read-only in a disposable git worktree removed on every terminal
path, with a restricted tool roster. worktree-write is schema-valid and refused
by the runtime with a code distinguishable from "this agent cannot" — the release
gate and the agent capability are different facts. The
harness page
carries the operator-facing tour, including what is not implemented.
Remote entry — the order is the control¶
keryx serve is a second door into the same harness. Its security properties
are ordering properties, which is why they are drawn rather than listed:
the same nine checks in a different sequence is a finding
(src/lib/serve-turn.ts:3-7, quoting security-policy.md).
flowchart TB
REQ["Inbound request"] --> B1["1 · Bound body size and content type<br/><i>before parsing semantics</i>"]
B1 --> B2["2 · Authenticate, constant-time<br/><i>serve-server.ts:680</i>"]
B2 -->|"fail"| F1(["one fixed 401 on every path<br/><i>a known route is indistinguishable<br/>from an unknown one</i>"])
B2 --> RT["Route<br/><i>URL first parsed at :709 —<br/>39 lines AFTER auth</i>"]
RT --> B3["3 · Stamp origin from the<br/>authenticated connection"]
B3 --> B4["4 · Resolve session identity-first<br/>from the declared project — never infer"]
B4 --> B5["5 · Prompt to `src/security` as<br/>UNTRUSTED content"]
B5 -->|"injection or secret finding"| F2(["no turn is created"])
B5 --> B6["6 · Remote profile compared to local<br/><i>may never be weaker</i>"]
B6 -->|"weaker"| F3(["refused"])
B6 --> B7["7 · Harness classifies each action"]
B7 -->|"ask"| F4(["recorded denial —<br/>approvals are R4d"])
B7 --> B9["9 · Redact every stream event,<br/>result, error body and notification"]
B9 --> OUT["SSE stream + durable turn record"]
Two properties people get wrong when summarising this:
- The prompt is scanned but reaches the provider unredacted. Only outbound
content is redacted (
serve-turn.ts:356-364discards the redacted string). - The approval boundary is enforced in the policy engine, not the transport.
policy/engine.ts:233-241convertsasktodenyfor a non-interactive context, and a remote turn is non-interactive by construction. The transport only reports it.
Containment — two tiers, and the platform split is not a footnote¶
The sandbox sits below policy: policy decides what runs, the sandbox bounds what a run can touch.
flowchart TB
subgraph T1["Tier 1 — both platforms"]
FS["Filesystem boundaries<br/>workspace-write · secret read-deny"]
NET["Network off / on"]
end
subgraph T2["Tier 2 — macOS ONLY"]
AL["Domain allowlist proxy<br/><i>loopback, reports each ruling</i>"]
TLS["TLS termination<br/><i>ephemeral run CA</i>"]
MASK["Credential masking<br/><i>needs TLS — fails closed without it</i>"]
end
CMD["A command to run"] --> POL["Policy decision"]
POL --> W{"wrap.ts:31<br/>platform dispatch"}
W -->|"darwin"| SB["Seatbelt"]
W -->|"linux"| BW["bubblewrap"]
SB --> T1
SB --> T2
BW --> T1
BW -->|"network: restricted"| REF(["REFUSED, with a reason<br/><i>wrap.ts:49-55 · asserted in CI</i>"])
Tier 2 does not exist on Linux and does not degrade there — it refuses. A
sentence like "keryx sandboxes commands" is misleading because it flattens that.
The Linux refusal is asserted by a live CI job; since 0.2.1 a macOS real-host
job exercises Tier 2, so the platform where the allowlist actually runs is no
longer the platform with no live test.
Two further things a reader needs:
- In the default configuration the load-bearing layer is the approval gate
(
src/commands/agent.ts,src/lib/shell-permissions.ts), becauseshell_execcontainment defaults tooff. - The network posture is an operator decision. Since
0.2.2it is resolved byresolveNetworkRestriction, which takes the operator's intent and not the ambient environment — a credential that merely exists on the machine can no longer choose it.
The .metaproject/ workspace contract¶
.metaproject/ is the product. Its internal contract distinguishes
source-of-truth files (human-editable, seed-once or hand-authored) from
generated data/ artifacts (disposable module outputs).
This is the single most load-bearing concept in the system, so it is worth one
picture: the distinction is who may write what, and it is what makes
keryx update safe to run on a workspace full of accumulated knowledge.
flowchart TB
subgraph MP[".metaproject/"]
direction TB
IDX["index.md<br/><i>the agent entrypoint</i>"]
subgraph SVC["Service files — reconciled by init / update"]
MOD["modules/*.md"]
SK["skills/"]
RU["rules/"]
MAN["metaproject.json"]
end
subgraph SOT["Source of truth — seeded once, then yours"]
WI["wiki/"]
ME["memory/"]
FL["flows/"]
RV["reviews/"]
end
subgraph DAT["data/ — generated, disposable"]
GG["gdgraph/artifacts/"]
GC["gdctx/artifacts/"]
HE["health/artifacts/"]
TE["testing/artifacts/"]
end
end
INIT["keryx init"] -->|"scaffolds"| SVC
INIT -->|"seeds, never clobbers"| SOT
UPD["keryx update"] -->|"reconciles to template"| SVC
UPD -.->|"NEVER writes here"| DAT
MODULES["module commands<br/>gdgraph build · health run · test analyze"] -->|"the only writers"| DAT
AGENT["Agents and humans"] -->|"read"| IDX
IDX -->|"routes to"| SVC
AGENT -->|"read and edit"| SOT
The rule that falls out of the picture: the lifecycle commands treat data/
as read-only, and the module commands are its only writers. That is what lets
a self-update refresh the toolchain without destroying accumulated project
knowledge.
One known violation of the spirit, recorded rather than hidden: generated memory
artifacts embed an absolutePath per entry, so a committed artifact encodes one
developer's home directory — which contradicts the "committable and readable by
anyone who clones" half of the contract.
.metaproject/
├── metaproject.json # MANIFEST — authoritative runtime config
├── index.md # agent entrypoint: module/rules/skills/data map
├── keryx-dashboard.html # self-contained human dashboard
├── *.config.json # per-module config, seed-once (gdctx/health/testing/memory)
├── rules/ # imported + distilled agent rules (source of truth)
├── skills/ # installed bundled skills (SKILL.md) — regenerated
├── project-skills/<m>/<n>/ # project skills (source of truth, human/agent-authored)
├── wiki/ # knowledge base pages (source of truth)
├── memory/ # typed memory entries (source of truth, Markdown)
├── flows/<NNN>-<date>-<slug>/# flow packages (flow.json is CLI-owned state)
├── modules/, core/, hooks/ # module manifests, vendored runtime, git hooks
└── data/ # GENERATED artifacts — never written by init/update
├── gdgraph/{storage,artifacts}/
├── health/{artifacts,history,raw}/
├── testing/{artifacts,history,context.md,logs}/
├── gdctx/{raw,artifacts}/
├── gdwiki/link-check/
└── memory/{index,artifacts}/
Who writes what. Each feature module owns its data/<module>/ subtree and writes only there at runtime. Source-of-truth trees (wiki/, memory/, project-skills/, rules/) are seeded by init/module new/create commands but are then "owned" by the human — the tooling guards against clobbering them (gdwiki's draft-marker guard only overwrites unmodified generated drafts; gdskills learn never mutates SKILL.md without an explicit apply; flow's flow.json is the CLI's exclusive writer, protected by an AC-checksum tamper check).
metaproject.json manifest. The single authoritative runtime config. It records schemaVersion, project name, paths{}, agentEntrypoints{root[], metaproject}, the Metaproject-Standard fields standardVersion / profiles[] / updatedAt, and a modules{} map where each entry carries enabled, per-module settings (e.g. gdskills profile + projectSkillRegistry[]), hooks{}, and a commands[] list. The lifecycle commands read it to know which of the 9 optional modules are enabled; rules, health, flow, and gdskills read it too.
MODULE_COMMANDS single source of truth. src/commands/module-commands.ts holds one canonical subcommand list per module id. moduleCommands(id) returns a fresh mutable copy consumed by init (buildManifest) and update (refreshServiceFiles, recovery, tasks-backfill) — so the commands[] arrays in every generated metaproject.json come from exactly one place, enforced by module-commands.test.ts. Note the id/verb skew: manifest id tasks ↔ CLI verb flow; id gdwiki ↔ verb wiki (legacy wiki keys migrated forward).
Cross-module data flow¶
keryx modules are loosely coupled through the .metaproject/data/ filesystem, not through direct calls. A module writes artifacts; another module reads them later. Historically only three edges were true in-process code imports; the rest are file-mediated. As of Phase 3 the security module adds five more inbound in-process edges — memory, wiki, testing, gdctx, and flow now call a shared security guard at their write seams (see "Security write-seam gates" below). The diagram below distinguishes the two — dashed arrows are file-mediated, solid bold arrows are in-process code imports.
flowchart TB
subgraph producers[Analyzers — write artifacts under data/]
gdgraph[gdgraph build]
health[health run]
testing[testing run]
memory[memory]
end
subgraph consumers[Consumers]
gdwiki[gdwiki collect]
gdskills[gdskills verify / learn]
dashboard[dashboard build cli-core]
end
%% file-mediated integrations (dashed)
gdgraph -. nodes/edges.jsonl .-> gdwiki
health -. latest.json .-> gdwiki
testing -. context.md .-> gdwiki
testing -. latest.json .-> gdskills
health -. latest.json .-> gdskills
gdskills -. projectSkillRegistry (metaproject.json) .-> health
gdgraph -. snapshot .-> dashboard
health -. snapshot .-> dashboard
testing -. snapshot .-> dashboard
gdwiki -. snapshot .-> dashboard
memory -. snapshot .-> dashboard
%% in-process code imports (solid bold) — 3 legacy edges
testing == loadCompatibleTestingReport ==> health
memory == relevantAcceptedMemory ==> gdskills
health == service.gate ==> flow[flow complete]
%% security write-seam gates (Phase 3) — 5 inbound edges
security[[security guard]]
memory == guardOutput ==> security
gdwiki == guardOutput ==> security
testing == guardOutput ==> security
gdctx[gdctx run/read] == redactRaw ==> security
flow == securityFlowGate ==> security
classDef code stroke-width:3px;
linkStyle 12,13,14 stroke-width:3px;
The five
securityedges are advisory by default (report and continue, never block); enforced/ci blocks or suppresses the guarded write.
The three in-process code imports (solid) are:
- testing → health — the health
testssource adapter reuses testing'sloadCompatibleTestingReportandTestingReporttype instead of re-running tests. - memory → gdskills
verify—verifycallsrelevantAcceptedMemory()to surface accepted decisions/constraints/known-mistakes so a skill can be flagged when it contradicts accepted memory. (verifyalso calls gdwiki'swikiValidate()as an evidence signal.) - health → flow — flow's completion gate 3 calls
createCodeHealthService().gate. (flow's context collector also runs a deterministicmemorysearch to enrichcontext.md.)
Security write-seam gates (Phase 3). Five modules now call a shared in-process guard (src/security/guard.ts) at their write seams — the first real inbound security calls in the system:
- memory ingest → security (
guardOutput, target memory) before writing each accepted entry; - wiki collect → security (
guardOutput, target wiki) before writing a collected draft; - testing run → security (
guardOutput, target report) before persisting the raw log; - gdctx run/read → security (
redactRaw) to redact secrets from raw output before persist/summarize; - flow complete → security (
securityFlowGate) as completion gate 4.
The guard wraps the frozen Phase 1+2 engine and enforces one rule: advisory (the default) reports and continues — it never blocks or mutates (the gdctx seam still redacts detected secrets as a pure safety step); enforced/ci blocks or suppresses the write with a masked category+count reason; disabled is a zero-cost no-op. It imports only from the security engine + shared libs (so the seam stays acyclic) and degrades to allow on any engine error, so a seam is never broken.
The file-mediated flows (dashed) are the backbone of the system:
- Fan-out from analyzers → gdwiki
collect.wikiCollectreads gdgraph'snodes.jsonl/edges.jsonl, health'slatest.json, and testing'scontext.md(all read-only, silently skipped if absent) and emits draftarchitecture/componentwiki pages. It never invokes those modules — it must run after they have produced data. - testing/health → gdskills
learn.skills learn --from-test/--from-healthingest the testing/health JSON artifacts to produce auditable learning proposals (which only mutate aSKILL.mdon an explicitlearn apply). - gdskills registry → health. health's
skills.tsreadsmodules.gdskills.projectSkillRegistryfrom the manifest to map files to their owning project-skill, producing the per-skillscope.skilltagging thatlearn --from-healthlater consumes — closing the loop. - Everything → dashboard. cli-core's
collectDashboardDatareads read-only snapshots from alldata/subtrees plus wiki/memory Markdown and embeds them into one self-contained HTML file.
Two external dependencies also cross module boundaries: the gh CLI feeds flow's tracker (issue body, PR draft/checks, completion comment) and, via git, testing's changed-file detection.
Lifecycle — init and update¶
The workspace is built and kept fresh by two idempotent lifecycle commands.
init (src/commands/init.ts) bootstraps .metaproject/:
- Parse
--yes/--no-<module>/--gdskills-profile/--no-*-hookflags into per-module enablement (9 modules default on; hooks default off). Interactive prompts when not--yes, with TTY-safe defaults when piped. - Scaffold base dirs plus per-enabled-module dirs (
core/,data/,skills/, and module-specific folders derived fromWIKI_PAGE_TYPES/MEMORY_TYPES). - Inject a managed
.gitignoreblock;syncAgentRulesseeds/updatesAGENTS.md/CLAUDE.mdand the routing block. - gdgraph copies vendored core scripts and renders a local
cli.ts; gdskills runsinstallGdskills(profile); testing runsanalyzeTestingProjectonce. - Install git hooks (idempotent managed blocks in
.git/hooks/*, no-op without.git): gdgraph/gdskills/health post-commit reminders, a dashboard post-commit hook, and an opt-in blocking testing pre-push gate (stays OFF even under--yes). buildManifestwritesmetaproject.json, managed docs,index.md, SKILL.md files, and the dashboard.
update (src/commands/update.ts) refreshes an existing workspace:
- Runtime self-update (unless
--skip-runtime): find the runtime git repo (.metaproject/runtime/keryx/.gitor$HOME/.keryx/…) andgit fetch --depth 1 origin main+git checkout --force FETCH_HEAD— the CLI updates the copy of itself it was launched from. refreshServiceFiles: read/normalize/recover the manifest (infer-from-dirs if missing, legacywiki→gdwikimigration), tasks backfill (auto-enable the flow/tasks module on pre-tasks workspaces unless--no-tasks), re-render all managed docs/manifests/skills, re-copy gdgraph scripts, reinstall each module's hook only if the manifest already records it, and rebuild the dashboard.- Reconcile the manifest and, with
--hooks, run every executable inhooks/post-update.d.
Throughout, both commands honor data-vs-service: they regenerate service files and report "Data artifacts were left untouched."
External integrations¶
keryx shells out to external tools but hard-depends on none — all are optional and degrade gracefully.
| Tool | Used by | Purpose |
|---|---|---|
| git | cli-core, health, testing, flow | Hook install; runtime fetch/checkout; churn/changed-file detection; AC/commit refs. Absent → hooks no-op, changed-set empty. |
| gh (GitHub CLI) | flow, testing | flow: fetch issue body, verify PR draft/checks, post completion comment. testing: (via git) changed files. Absent → gates skipped. |
| eslint | health | Lint findings (error→P1, warning→P2). |
| tsc | health | tsc --noEmit type diagnostics (error→P0). |
| bun test / project runner | testing, health | Run the project's existing runner; health reuses the normalized report. |
| coverage (Istanbul) | health | Import coverage-summary.json → coverage penalty + soft-floor gate. |
| dependency audit (bun/npm) | health | audit --json → dependency findings. |
| SonarQube | health | Import-only sonar-issues.json (disabled by default; $SONAR_ISSUES_FILE). |
| ripgrep (rg) | gdctx | Search summarization. |
| codex / claude runtimes | gdskills | export/sync project skills to runtime skill folders (explicit target only, never auto-writes global dirs). |
gdskills also ships five JSON Schema contracts (subagent-dispatch/-result, agent-event, orchestrator-state, review-finding) with a hand-rolled draft-2020-12 subset validator for orchestrator/subagent interop — an interop surface, not a network API.
Cross-cutting conventions¶
- Layered pattern everywhere —
cli → command → service → lib; handlers stay thin, domain logic is free functions overcwd, presentation is isolated intemplates.ts. - Config-table-driven types — a single
constregistry is the source of truth for a domain's shape:WIKI_PAGE_TYPES(8 page types → folders),MEMORY_TYPES(11 entry types),BUNDLED_GDSKILLScatalog,MODULE_COMMANDS, health'sFINDING_ADAPTERS. Types, folders, validation, and rendering all derive from these. - Config deep-merge with defaults — every module config (
*.config.json) is optional and merged over a hardcodedDEFAULT_*_CONFIG; a missing or malformed file silently falls back to defaults. - Fail-soft I/O —
pathExists/readJsonFileOrswallow errors; JSONL parsers skip bad lines; missing external tools return null rather than throwing.readJsonFileis the one helper that rethrows (with the file path) for actionable manifest errors. - TTY-graceful UI —
ui.tsgates color onNO_COLOR/FORCE_COLOR/isTTY;prompt.tsreturns defaults when stdin is not a TTY, so CI and piped runs never hang or emit ANSI noise. - Offline-safe self-contained HTML — the dashboard inlines all CSS/JS and embeds size-capped Markdown/JSON, because
fetchis blocked underfile://. - Deterministic, no-ML floor (with opt-in ML ceilings) — the default algorithms are deterministic and dependency-free: memory search/dedup use token/trigram Jaccard; health complexity is token-based cyclomatic counting; gdgraph parsing is regex, not AST. Time is injected (
now: Date) for testability. Heavier ML/AST behaviors (embeddings, tree-sitter, model-based detection) exist only as Capability-Seam ceilings that fall back to this floor when unresolved — see "Capability System — opt-in layers". - Co-located
*.test.ts(bun test) — tests sit beside sources throughout; the quality gate istsc --noEmit && bun test.
Known limitations / tech-debt¶
- Declared-but-unused config (aspirational flags). testing's
changedSelection.strategies(esp.gdgraph),runner:"script"|"direct", andartifacts.historyLimit/keepRawLogsare surfaced but not honored (selection is always heuristic, history is never pruned, no gdgraph wiring). memory'singest.allowAutoAcceptis defined but never read (ingest always writesdraft). (health's churn is now a scoring input — it feeds the additive D1 hotspot penalty,churn×complexityper file viahotspotPenalty,src/health/scoring.ts— but itsscoring.hotspotWeightdefaults to0, so the penalty is exactly inert until explicitly enabled.) - Duplication / drift risk. cli-core keeps near-duplicate copies of
installManagedHook,writeTextIf*,copyFileIfChanged,runtimeSourcePath, andescapeRegExpin bothinit.tsandupdate.ts. rules maintains routing-policy strings in two places and has two parallel entrypoint-discovery functions. memory has twoslugifyimplementations. health'ssourcessubcommand duplicates status-detection logic separate from the run path. - Facades defined but not wired.
GdWikiServiceand analogous service facades exist for DI/testing symmetry, but the CLI handlers call the free functions directly. memory'sreflect/relevantdeliberately sit outside theMemoryServiceinterface. - Testing gap in
rules.src/rules/has no co-located tests, unlike the rest of the codebase; its highest-risk untested logic is the heuristicclassifySectionand the policy migration/de-dup inensureMetaprojectReference. - Heuristic precision limits (deliberate trade-offs). gdgraph import extraction is regex (can miss unusual syntax) and reports one representative per canonical cycle rotation; testing's failure/count parsers are bun-test-shaped (approximate for vitest/jest/playwright); health complexity is token-based, not AST.
- Naming skew. module id
tasks↔ CLI verbflow; module idgdwiki↔ CLI verbwiki(legacywikimanifest key migrated forward);gdctxhas nosrc/ctx/dir (logic lives entirely incommands/ctx.ts). - External agent runtime — verified offline only, and supervision is unbuilt. The whole
src/harness/external/layer is tested against recorded transcripts infixtures/external/; no part of it has been run against a realcodexorclaudeprocess, so version drift in either CLI is caught by a parse-skip counter rather than by a passing test. The specification's folded, trigger-driven view of a running external child is not implemented at all — the parent receives the child's result and nothing before it.worktree-writeis refused, so only read-only dispatches execute./delegatebypasses the policy engine and the subagent admission ledger (a recorded, reasoned amendment, not an oversight). Resume argv is built and displayed for detaching by hand; keryx never spawns a resume itself. - Security — gateway mode still pending. The
securitymodule ships Phase 1+2+3 (deterministic engine + CLI + the write-seam integrations wired at memory ingest, wiki collect, testing raw-log publish, gdctx raw-output redaction, and flow completion — see the cross-module data-flow section) plus shipped Phase-4-class surfaces: merge-safe agent security hooks (security hooks install --runtime …,src/security/agent-hooks.ts), a config-checksum self-protect tamper guard (src/security/self-protect.ts), and a model-eval path (security eval --with-model,src/security/eval/) that forces the asset-gated injection/PII model backends on (warn-once → deterministic fallback when the asset is absent). What remains unimplemented is chiefly the always-on gateway/proxy mode (spec §16 Phase 4).