Skip to content

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.

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.

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.
  • 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.

Syngraphe v0.4.1 · context schema v1 · GitHub · Donate