---
title: "Architecture"
description: "How Syngraphe is put together — the dependency direction, the plan/apply core, and the two registries."
source: https://syngraphe.dev/concepts/architecture
context-schema-version: 1
---

# Architecture

Syngraphe is a Node.js/TypeScript CLI shipped as ESM. The deterministic core is offline: it touches
the filesystem and the system `git` executable, and nothing else. `commander` is the only runtime
dependency.

## Dependency direction

```text
cli → commands → core / inspectors → filesystem, Git
```

One way, no cycles.

- **`src/cli/`** — argument parsing, the output sink, and the mapping from errors to exit codes.
  Thin on purpose: no domain logic lives here.
- **`src/commands/`** — `init`, `status`, `check`, `stats`, document operations and scope reporting. Each command is a composition root: it gathers
  inspections, consults the registries, renders, and applies.
- **`src/core/`** — `Repository` (path safety and repository-relative IO), `fs` (the only module
  that writes), `git` (a thin `execFile` wrapper), `plan` and `render-plan`, plus small text and
  Markdown helpers.
- **`src/managed/`** — the managed-block subsystem. `block.ts` is pure text manipulation;
  `file.ts` binds it to real files and produces plans.
- **`src/agents/`** — the `AgentIntegration` contract, the registry, one adapter per agent, and the
  `AGENTS.md` bootstrap that belongs to no single agent.
- **`src/checks/`** — the `Check` contract, the shared snapshot every check reads, one module per
  check, the registry and the runner.
- **`src/inspectors/`** — read-only classification and size/bloat inspection of `.context/`.
- **`src/templates/`** — the generated file contents and the managed-block bodies.

Agent integrations and checks are **registries consumed by commands**, never conditionals scattered
through the core. That is what keeps "add an agent" and "add a check" to one module plus one line
plus tests.

## Inspect, plan, render, apply

Every command that can modify the repository goes through four separable stages:

```text
inspection  →  planning  →  display  →  apply
```

Inspection never writes. Planning never writes. Only `applyPlan` writes, and it applies a plan built
by someone else.

```ts
type FileOperation =
  | { type: "create"; path: string; contents: string; summary?: string }
  | { type: "patch"; path: string; before: string; after: string; summary: string };

interface Plan {
  operations: FileOperation[];
  unchanged: UnchangedEntry[];
  conflicts: PlanConflict[];
}
```

Three consequences fall out of this, none of which needed extra code:

- **`--dry-run` is honest.** It runs the same planner and returns before `applyPlan`. There is no
  second implementation that could disagree with the real one. Planners receive a
  `ReadOnlyRepository`, a type with no method that writes, so a planner that tried to write would
  not compile.
- **Plan JSON is a projection, not serialization.** It preserves operation order and paths while
  excluding created contents and patch `before`/`after` states from the public contract.
- **Conflicts stop everything.** A plan carrying conflicts is never partially applied — a
  half-applied plan is harder to reason about than one that did not run.
- **Stale plans fail loudly.** `patch` operations record the exact expected `before` content, and
  every precondition is verified before the first write. A file that changed underneath produces an
  error, not a clobber. The comparison is repeated inside publication, so an edit that lands after
  that first check is caught too.

The same shape is what a future `update` or `reconcile` command would reuse; nothing in the plan
types is specific to initialization.

## GitHub Action adapter

The root `action.yml` runs `action/dist/index.cjs` on the runner's Node 24 runtime. Sources in
`action/src/` call the existing check runner, check exit policy, scope discovery and stats inspector.
The adapter parses workflow inputs, maps paths for annotations and collects a versioned report;
it never shells out to the CLI or executes code from the checked-out project.

The generated bundle includes all runtime dependencies. `@actions/core` handles GitHub outputs,
annotations and summaries; it is a development dependency because the bundle is distributed through
Git, separately from the npm CLI. Esbuild generates that bundle and third-party notices;
`action:check` compares a fresh in-memory build to committed files. Tests validate metadata with
`yaml`, another development-only dependency. CLI package contents and production dependencies stay
the same.

Action reports are written through the filesystem abstraction to fresh runner temporary directories.
The GitHub toolkit writes the runner-provided output and summary files. No workflow reports are
written into the inspected checkout. See [the Action reference](https://syngraphe.dev/reference/github-action).

## Managed blocks as a generic subsystem

`src/managed/block.ts` knows about text and nothing else — not `AGENTS.md`, not Claude, not agents at
all. Every function is a pure string transformation:

```ts
findManagedBlock(content)
validateManagedBlock(content, expectedBody)
insertManagedBlock(content, body)
replaceManagedBlock(content, body)
removeManagedBlock(content)
```

Purity is what makes the guarantees testable exhaustively rather than sampled: that insertion is
exactly reversible, that CRLF survives, that a missing final newline survives, that a hand-edited
block is detected. See [managed blocks](https://syngraphe.dev/reference/managed-blocks).

`src/managed/file.ts` is the thin layer that reads a real file, classifies its block, and turns that
classification into a plan. Both `AGENTS.md` and `CLAUDE.md` go through it.

## The two registries

```ts
interface AgentIntegration {
  id: string;
  displayName: string;
  findingCodes?: AgentFindingCodes;
  detect(repository: ReadOnlyRepository): Promise<AgentDetection>;
  inspect(repository: ReadOnlyRepository): Promise<AgentIntegrationState>;
  planIntegration(repository: ReadOnlyRepository): Promise<Plan>;
}

interface Check {
  id: string;
  label: string;
  category: CheckCategory;
  run(context: CheckContext): Promise<Finding[]>;
}
```

Integrations declare their own finding codes, so an agent's failure modes are described by its
adapter rather than by a switch statement somewhere in the check layer. An integration that cannot
fail — one that reads `AGENTS.md` natively — declares none and contributes no check.

## One snapshot per run

`check` and `status` build a single `CheckContext` — the repository, the context inspection, the
`AGENTS.md` state, every agent's state, and the reference instant for age-based checks — and every
check reads from it.

Two reasons. A run is internally consistent: no check can observe a repository that changed halfway
through. And `status` reuses the same checks `check` runs, so the two commands can never disagree
about how many findings are open.

Injecting `now` rather than calling `Date.now()` inside a check is what makes the freshness tests
deterministic instead of dependent on when they run.

## Filesystem discipline

All writing goes through one module. Commands never call `node:fs`.

- Writes are complete-file writes: the file is written in full into a temporary file in the
  destination directory and then published from there, so an interrupted run cannot leave a
  half-written file and publication stays on one filesystem.
- Publication follows the intent, and each intent is verified by the operation that publishes it. A
  create is linked into place: linking fails when the destination exists, so checking that a path
  is free and claiming it are one operation and two concurrent creates cannot both win. A patch
  renames the file aside, compares the moved bytes with the plan's expected content, and then links
  the new file into place — or links the original back when it changed. The bytes compared are the
  bytes replaced; the only writer left out is one that already has the original open and writes
  through that descriptor after the comparison.
- Without hard links (FAT, some network shares), both fall back to an exclusive create: still
  exclusive, but a crash during that write can leave the new file partial.
- A patch starts from the file's exact bytes. A file that is not valid UTF-8 would have its
  undecodable bytes replaced, so it is reported as a conflict instead.
- `Repository.resolve` rejects absolute paths and anything that escapes the selected scope. References may reach shared context inside the Git root.
- Writes capture parent-directory identities from Git root and recheck them during staging,
  publication, fallback and cleanup. A detected symlink or replacement stops the operation.
  Missing directories are created one level at a time; temporary files are created exclusively.
  These path-based checks do not exclude hostile swaps inside an individual check/syscall interval;
  see the [safety model](https://syngraphe.dev/concepts/safety-model).
- Directory walks use `lstat` and do not follow symlinks.

## Git usage

Deliberately minimal. Three operations, both through `execFile` with an argument array — never a shell
string, so a repository path can never be interpreted as a command:

- `git rev-parse --show-toplevel` — locate the repository root.
- `git log -1 --format=%cI -- <path>` — the last commit date of a path, for freshness.
- `git ls-files --cached --others --exclude-standard -z` — deterministic monorepo scope discovery.

A missing executable, a non-repository directory, and a failed command are all the same answer: *no
information available* for root discovery and freshness. Checks that depend on commit dates then
stay silent rather than guessing. Scope discovery fails explicitly if Git cannot enumerate files.

There is no `simple-git` and no libgit binding: these commands do not justify a dependency, and
`execFile` with an argument array is the safer call anyway.

## Scope and document composition

`Repository.inScope` selects an existing directory inside its `gitRoot`. Existing inspections,
checks and planners operate unchanged on scope-relative paths; the Git client runs in that scope,
so freshness follows its own activity. Root selection remains the default. The scoped bootstrap
has its own canonical body that explains shared ancestor context and explicit check selection.

`discoverScopes` discovers contexts from tracked and unignored files. Read-only commands aggregate
reports using a separate JSON envelope for `--all`; single-scope check JSON is unchanged.

Truth, decision, state and history document creation and current-state archival use the existing
create/patch plan. Archive creates history before resetting current state, and all path and content
preconditions are checked before the first write. The independently versioned plan JSON projection
preserves operation order and scope-relative paths without exposing created contents or patch
states. Stats is a read-only traversal, with exact byte counts, a documented token heuristic and
advisory size/duplication signals.

## Testing

`node:test` and `node:assert`, no framework.

Integration tests create real temporary Git repositories in the system temporary directory, run the
CLI in process against them, and remove them afterwards. They cover the cases that matter for a tool
that edits user files: an existing `AGENTS.md` with and without a heading, an existing `CLAUDE.md`, a
symlinked one, CRLF files, files without a final newline, duplicate blocks, hand-edited blocks, an
unrelated `.context/`, an unsupported schema.

Two properties are asserted directly because they are product invariants rather than implementation
details: **idempotency** (init, snapshot, init again, snapshots equal) and **preservation** (init,
remove the managed block, the file equals the original byte for byte).

## Designed to extend, not extended yet

Several things are deliberately absent but not designed out: `doctor`, `update`, `reconcile`, schema
migrations, and an eventual `AgentRunner` for semantic analysis by an installed agent. Each of them
fits the existing seams — plans, registries, the schema version in the manifest — which is why they
are named here and implemented nowhere. See [scope and non-goals](https://syngraphe.dev/concepts/scope-and-non-goals).
