Architecture, modules, and mixins

ai-lib separates deciding which guidance applies from writing that guidance to disk. The catalog describes ownership and relationships. Detection produces repository evidence. Resolution turns a selection into an effective instruction set. Materialization produces files, while reconciliation compares the current installation with a newly proposed one.

Implementation layers

Source areaResponsibility
src/cli.ts and src/commands/Clipanion command registration, options, output, and orchestration.
src/components/React and Ink components for the interactive catalog browser.
src/lib/catalog.tsJSON manifest loading, detector imports, and catalog validation with Zod.
src/lib/detect.tsRepository and workspace discovery, detector contexts, and evidence collection.
src/lib/resolve.tsPreset expansion, dependency ordering, conflict checks, and mixin activation.
src/lib/materialize.tsFile plans, provenance markers, SHA-256 hashes, inspection, and writes.
src/lib/reconcile.tsProposed stack selection and differences between current and desired state.
src/lib/stack.tsYAML stack persistence and stack-format handling.

These layers share typed data structures rather than relying on terminal output as an internal protocol. For example, a reconciliation plan contains both current and proposed resolutions, selected and effective module differences, active mixin changes, override-path differences, and a complete materialization plan.

Modules own one concern

A module lives in catalog/modules/<folder>/module.json. Its stable id is the reference used by presets and stack state; it need not match the folder name. The manifest describes its name, version, category, dependencies, conflicts, source assets, managed destinations, and local override locations.

The TypeScript module is lang/typescript. It depends on global/core, owns shared instructions under .github/instructions/shared/typescript/, and points repository-local customization at .github/instructions/local/typescript/.

Its assets are separate documents covering TypeScript architecture, function-owned namespace types, scripts and builds, tsconfig layout, runtime validation, Node.js API typings, native execution, and testing. Keeping those assets separate does not make them separate selectable modules: selection is still at the manifest level.

managedPaths declares ownership, while sourceAssets lists the actual files to install. The materializer maps an asset such as files/instructions/typescript-testing.instructions.md into the module's managed instructions directory. overridePaths advertises where local guidance belongs; it does not itself create those files or implement a text-merging mechanism.

Detection belongs beside the module

An optional detect.mjs file default-exports a detector. It receives the target's repository and project paths, optional package metadata, an exists helper, and a dependency helper.

The actual TypeScript detector is small:

detect.mjs
export default function detect({ dependency }) {
const evidence = dependency('typescript');
return {
applies: evidence.length > 0,
reason: 'The target declares TypeScript in its package manifest.',
evidence,
};
}
js

The dependency helper checks dependencies, devDependencies, peerDependencies, and optionalDependencies. A positive result carries both a decision and evidence suitable for explaining the selection. This detector does not infer TypeScript usage merely from a .ts file or a tsconfig file.

Repository discovery uses @sabinmarcu/utils-repo to locate the root and declared workspace paths. Each target is detected separately. Dependency lookup uses the target's parsed package manifest; it is not a lookup through the installed transitive dependency graph.

Detectors are executable JavaScript loaded through dynamic import, not sandboxed declarative expressions. A custom catalog must therefore be trusted as code as well as reviewed as guidance.

Presets bundle, dependencies specialize

A preset is a named list of ordinary modules. The current web preset is:

node-web.json
{
"id": "node-web",
"name": "Node Web Application",
"description": "Composable baseline for TypeScript-driven Node web projects.",
"modules": [
"global/core",
"tooling/yarn",
"lang/typescript",
"guardrails/web-platform",
"guardrails/web-style",
"arch/react",
"arch/web-application"
]
}
json

A preset is not a snapshot of all installed files. The resolver still expands module dependencies and activates mixins. Repository-root policy, for example, is contextually detected rather than listed in this application preset.

An ordinary dependency expresses an unconditional relationship. arch/node-library depends on arch/node-package-library, which depends on arch/node-package. A Node.js library always needs the shared library publication and package guidance; that is not an optional intersection.

Resolution deduplicates and sorts requested IDs, then traverses sorted dependencies before each dependent module. It rejects unknown IDs, dependency cycles, and conflicts present in the effective set. For example, arch/node-library declares a conflict with arch/web-library; selecting both is not resolved by whichever was listed last.

Mixins own combination-specific rules

A mixin lives under catalog/mixins/<folder>/mixin.json. Its requiresAll field names ordinary modules that must all be effective. Mixins cannot be put in presets or directly selected in stack state. They are derived after dependency resolution and do not create another dependency chain between mixins.

The TypeScript/ESLint mixin is a complete example:

mixin.json
{
"id": "mixin/typescript-eslint",
"name": "TypeScript ESLint Integration",
"description": "TypeScript peer dependency and validation requirements for the shared ESLint configuration.",
"version": "0.1.0",
"managedPaths": [
".github/instructions/shared/mixins/typescript-eslint/"
],
"sourceAssets": [
"files/instructions/typescript-eslint.instructions.md"
],
"overridePaths": [
".github/instructions/local/mixins/typescript-eslint/"
],
"requiresAll": [
"lang/typescript",
"tooling/eslint"
]
}
json

TypeScript alone does not imply ESLint, and ESLint alone does not imply TypeScript. Their combination needs a particular integration: load typescript-eslint alongside the shared ESLint configuration, keep parser and plugin configuration owned by that baseline, and inspect the effective configuration for representative TypeScript files.

The mixin's instructions explicitly treat a missing optional peer as a setup failure even if the shared configuration silently skips TypeScript support. That rule belongs to neither ordinary module in isolation.

Resolution pipeline and mixin activation example

To see the complete multi-stage architecture in action, consider a Node library project where detection identifies arch/node-library, lang/typescript, and tooling/eslint:

Execution details per stage

  1. Stage 1 (Selection & Detection): The stack config or repository detector identifies primary requested modules (arch/node-library, lang/typescript, tooling/eslint).
  2. Stage 2 (Transitive Dependency Expansion): Resolution performs a depth-first traversal of dependsOn lists. arch/node-library pulls in arch/node-package-library, which pulls in arch/node-package. Duplicate dependencies are merged and validated for conflicts.
  3. Stage 3 (Mixin Intersection Evaluation): After ordinary modules are fully resolved, mixin conditions (requiresAll) are evaluated. Notice that mixin/typescript-library activates because arch/node-package-library was added during Stage 2 dependency expansion—mixins evaluate against the full effective module set, not just direct selections.
  4. Stage 4 (Asset Materialization & Entrypoint): Each effective module and active mixin contributes its source assets to disk. SHA-256 hashes and owner metadata (ownerId and ownerVersion) are indexed in .ai/materialized.yml for drift detection during future reconciliations, while .ai/AGENTS.md is compiled to reference all active instructions.

The library mixin aligns TypeScript emission with the library publication contract: src to dist, public declarations in dist, matching package type exports, and no tests or stories in the build output. When another builder owns emission, its configuration owns that contract instead of duplicating it in TypeScript.

After introducing the relevant dependencies into an existing repository, the operational workflow is:

ai detect
ai reconcile
ai reconcile --apply
ai status -v
sh

Review the plan before applying it. No --module mixin/typescript-eslint argument is needed or accepted. If ESLint later leaves the effective set, that mixin deactivates; shared library guidance can remain. Existing presets or another module's dependencies may still keep a module effective, so removing a direct selection is not necessarily enough to deactivate it.

Decide where a new rule belongs

Use a module for an independently applicable concern and a dependency when one concern always specializes another. Use a mixin when concrete behavior changes only at the intersection of multiple ordinary modules.

For example, invoking lint-staged from a Husky pre-commit hook belongs to mixin/husky-lint-staged: neither installing Husky nor configuring lint-staged alone implies that invocation. A general recommendation to use code quality tools is not enough to justify a mixin.

Inspect the dependency closure before adding an intersection rule. Otherwise a mixin can duplicate guidance that is already unconditionally inherited. Give each mixin its own managed destination rather than relying on ordering to overwrite a module's file.

Ownership is explicit, not last-write-wins

Catalog loading validates manifest shapes, referenced IDs, source asset existence, relative paths, duplicate values, and dependency cycles. It rejects duplicate normalized managed-path declarations across modules and mixins. Materialization separately rejects two active owners producing the same target file.

This is not a generic deep merge of configuration objects. Each installed file has one owner, one owner version, and one desired content hash. A local override is a separate instruction location, not a patch silently applied to the shared asset.

Reconciliation uses those ownership records to plan additions, updates, and removals. Its write phase is sequential, not an atomic filesystem transaction with rollback. The CLI replans after applying to report remaining issues, but a failed write should still be inspected before retrying.

Built and maintained by Sabin Marcu

(2025 -2026)

Table of contents

Experiments