github.com/Goldziher/ai-rulez/v5/pkg/airulez loads a project, validates it, plans or applies a generate run and writes or checks its lock from Go, inside a process that may serve many projects at once. It is Experimental for its first minor release: after that, pkg/... changes only additively inside a major version (CI runs gorelease against the last release). Everything under internal/ is private.
ws := airulez.NewMemWorkspace()
ws.Set(".ai-rulez/config.toml", "version = \"5.0\"\nname = \"svc\"\npresets = [\"claude\"]\n", 0o644)
ws.Set(".ai-rulez/rules/style.md", "# Style\n\nBe concise.\n", 0o644)
project, err := airulez.Load(ctx, airulez.Options{Workspace: ws})
plan, err := project.Plan(ctx, airulez.PlanOptions{})
fmt.Println(plan.Digest, len(plan.Files))A Workspace is the read view of a project tree (an io/fs file system plus Lstat and ReadLink). Three implementations ship:
| Constructor | Reads |
|---|---|
DirWorkspace(dir) |
a directory of the real file system |
NewMemWorkspace() |
files you Set in memory |
GitSnapshot(ctx, repoDir, rev, runner) |
the tree of a commit, nothing checked out |
A symlink resolves only inside the workspace. A symlink the load refuses goes to Options.Logger, and validation fails on one in the project's own content; without a logger nothing is written to stderr. A snapshot has no machine-local files, like --no-local. A directory workspace ignores them too unless you set Options.WithLocal: machine-local files (config.local.toml, .ai-rulez/local/) carry the trust of the machine that wrote them, which a service loading someone else's tree should not grant.
Project.Plan lists every file a run would write, merge into or remove, with a digest of the rendered content, and writes nothing. Plan.Digest is the SHA-256 of the canonical plan document (schema/plan.schema.json, also what ai-rulez generate --emit-plan prints). It covers the sources and the existing outputs and manifests the workspace holds, so it is not a fingerprint of the sources alone: the same sources in a fresh directory and in one holding earlier outputs give different digests. The plan reads what already exists from the workspace itself: the previous manifest, merged documents such as .claude/settings.json and hand-edited outputs. A workspace in memory or in a commit therefore plans its own removals (stale files, entries taken back out of merged documents) the way a directory does. A machine-local record found in a commit is not believed, because a commit holds whatever its author chose.
Project.Generate applies a run: Write (directory workspaces only; anything else fails with CodeDiskRequired before touching anything), DryRun (the action list) or Check (files that differ from the plan). The same appliers back generate, generate --dry-run, generate --check and generate --emit-plan.
Project.Validate checks the configuration; ValidateOptions.Strict also runs the content and security checks (stable AR codes) and needs a directory workspace. Git, used to index tracked files, runs through Options.Runner; with the default DenyAll() the directory is walked instead. A canceled context stops Validate between its steps.
Project.Lock writes .ai-rulez/ai-rulez.lock the way ai-rulez lock does, through the same code and with the same gates: the minimum release age of version ranges, the security scan (AR001-AR009) of every remote tree about to be pinned and of every served skill, the deny list (AR717) and the carry-over of approvals. LockOptions mirrors the command's flags (Names, Kind, ContentOnly, Profile, Roles, AcceptFindings, Strict). Both Lock and LockCheck need a directory workspace (anything else is CodeDiskRequired).
- A remote tree whose scan has error findings, or with
Stricta served skill the scan refuses, fails withCodeFindingsand writes nothing.errors.As(err, &fe)with a*airulez.FindingsErrorlists each one as aLockFindingwith itsARcode. This is exit 2 of the command. - Without
Stricta refused served skill is left out of the lock and reported inLockResult.Unpinned, while the rest is written. This is exit 3 of the command. - Remote sources are re-resolved through
Options.Runneronly when the project was loaded withOptions.Remote. Release times are then also asked of the forge over HTTPS; withoutRemotethe forge is never contacted. - The
Projectkeeps the configuration it was loaded with.Loadagain to plan against the new lock.
Project.LockCheck is ai-rulez lock --check without the network: it compares the lock with the sources, the rendered outputs and the cached remote content, and returns LockStatus{InSync, Changes, Notes}. Cached remote content that disagrees with the lock is drift (InSync false), not an error. The remote tag (--verify-tags) and signature checks of the command are not part of it. A project with no lock, and no [lock] enforce, fails with CodeLock.
Loading and planning use no working directory, no process environment, no clock and no subprocess unless you give them: pass Options.Env, Options.Clock, Options.Runner (default DenyAll(); implement the Runner interface with the public Spec and Result types to deny, record or sandbox commands) and Options.Logger (every warning of a load, validation or plan goes there, and a service that gives none gets none). A load is bound by no organization policy unless one is given to it; the process-wide policy of the command line does not reach a library user. Remote includes and installed skills are fetched only with Options.Remote, through your Runner, with Options.GitToken. An ai-rulez.lock is enforced exactly as by ai-rulez generate: when the lock exists (and [lock] enforce is not false), a remote source it does not cover fails Load. Two Projects share no state, so a service can plan different projects concurrently; the operations of one Project are serialized.
The agent subcommand, file watching, init prompts, scanners, usage recording, the eval runners and the MCP server stay in the command line.
- Additive changes in minor releases; breaking changes only with a new major version and
/vNpath. - New surface is
Experimentalfor one minor release, marked in its godoc. - The plan document has its own
schemanumber: a new field is additive, a removed or re-typed one raises it and is announced in the changelog. - Errors are
*airulez.Errorwith a stableCode(load,plan,validate,apply,disk-required,refused,lock,findings); the cause is reachable witherrors.Isanderrors.As.refused(errors.Is(err, airulez.ErrRefused)) is a run that would overwrite a file ai-rulez cannot prove it wrote; nothing was written.