Skip to content

feat(loop): declarative domain loops — @decocms/loop CLI + domain-lint skill#5184

Open
viktormarinho wants to merge 3 commits into
mainfrom
viktormarinho/domain-loops
Open

feat(loop): declarative domain loops — @decocms/loop CLI + domain-lint skill#5184
viktormarinho wants to merge 3 commits into
mainfrom
viktormarinho/domain-loops

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Bootstrap for domain loops: operate repo domains declaratively — each domain has a versioned declaration (domains/<name>/DOMAIN.md), a reconciler agent turns drift into small PRs, and a human owner reviews and refines the declaration. Kubernetes-style reconciliation applied to code: reconcile(domain) → 0 or 1 PR, state lives in git/GitHub, rejected PRs must become declaration edits.

What's in the box

  • packages/loop (@decocms/loop) — zero-dep CLI:
    • init — scaffold domains/ + install the domain-lint skill into the target repo
    • lint <domain> — semantic lint of a declaration: mechanical checks + red-team ("what useless PR does this text authorize?")
    • run <domain> — one reconcile: open-PR lock (label loop:<domain>), throwaway worktree off main, claude -p, fix ONE violation, PR or nothing
    • tick — cron target: reconcile every domain you own (idempotent via the lock)
    • status / status --ui — inbox: in-flight PRs (checks/review state), running agents, schedule state; --ui is a live-refreshing TUI
    • cron setup|on|off — schedules tick on the OS scheduler (macOS launchd; survives sleep, bakes PATH into the job)
    • The reconcile protocol lives in src/reconcile-prompt.ts — versioned with the package, not installed anywhere
  • .cursor/skills/domain-lint — the semantic-lint skill (3 passes: mechanical, red-team, authoring/restructuring)
  • domains/DOMAINS.md + template — the index; a merged row = a live domain
  • apps/web/src/i18n/placeholder-parity.test.ts — unit test encoding the placeholder contract (pt-br placeholders ⊆ en placeholders); found and encodes the real {plural} case in orgs.members.connectionCount

Already proven in production

The pilot declaration (domains/i18n/, shipped in #5170) went through 8 red-team rounds of loop lint — each round found real holes (TODO-marker escape hatch, non-convergent audit loops, spec self-editing, .ts toasts invisible to .tsx greps, deletable satisfies clauses). The first real loop run i18n:

  1. correctly stopped on a stale declaration when refactor(studio): split API and web workspaces #5120 split the workspaces under us (flagged the owner instead of guessing), and
  2. after the owner path-fix, opened fix(i18n): translate assignee picker strings #5170 — a small, evidence-backed PR reusing existing dictionary keys.

Testing

  • bun x tsc --noEmit -p packages/loop
  • bun test apps/web/src/i18n ✓ (7 tests, incl. the new parity test at its post-refactor path)
  • bun run fmt / bun run lint / bun run knip ✓ (no new warnings)
  • Live: status, status --ui, tick (no-op path), cron (read path), init, 8× lint, 3× run (stale-stop, lock-skip, full reconcile → fix(i18n): translate assignee picker strings #5170)

Notes

  • domains/i18n/ itself ships in fix(i18n): translate assignee picker strings #5170 (the reconciler's own PR) — no file overlap between the two PRs, either merge order works.
  • cron setup is macOS-only for now (launchd); other platforms get a printed crontab suggestion.

🤖 Generated with Claude Code


Summary by cubic

Introduce declarative domain loops with the @decocms/loop CLI and a domain-lint skill to declare, lint, and reconcile repo domains via small, evidence-backed PRs. The CLI now ships as a single Node-target bundle (no runtime deps; Bun is build-time only).

  • New Features

    • @decocms/loop CLI: init, lint, run, tick, status, status --ui, cron setup|on|off.
    • Reconcile protocol (src/reconcile-prompt.ts): fix one violation per run; opens 0–1 PR labeled loop:<domain>; uses a throwaway worktree off the default branch.
    • domain-lint skill (installed by init): mechanical checks, red‑team for loopholes, and authoring/restructuring.
    • Domain scaffolding: domains/DOMAINS.md index and domains/DOMAIN.template.md.
    • Test: apps/web/src/i18n/placeholder-parity.test.ts enforces pt‑br placeholders ⊆ en.
    • Stability and packaging: CLI is a Node-target bundle (no runtime deps; Bun not required); batched GitHub PR listing; resilient run/tick/status behavior (cleanup no longer throws; tick/TUI survive failures); domain name validation; hardened DOMAINS.md parsing anchored to the last cell with unit tests; cron plist XML-escaped and on/off idempotent.
  • Migration

    • Run npx @decocms/loop init (or bunx @decocms/loop init), copy domains/DOMAIN.template.md to domains/<name>/DOMAIN.md, and fill it in.
    • npx @decocms/loop lint <name> to tighten the declaration; then npx @decocms/loop run <name> to reconcile.
    • Optional: schedule with loop cron setup [minutes] (macOS only) or add your own crontab/systemd entry calling loop tick.

Written for commit 402348d. Summary will update on new commits.

Review in cubic

…t skill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gh calls, harden error paths

- bin now points at bun-bundled dist/cli.js (inlines @decocms/shared/std;
  no runtime deps; engines: bun) — publishable standalone
- one batched 'gh pr list' for all domains (statusUi was 1 call/domain/15s)
- run() lock reuses the same query; cleanup in finally no longer throws
- tick survives a failing domain; TUI survives a failing gh refresh
- domain names validated as safe path/branch components
- DOMAINS.md parser extracted pure + unit tests; owner anchored to last cell
- plist values XML-escaped; cron on|off tolerates already-on/off
- README: Bun requirement + security model (merged DOMAIN.md = capability grant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@viktormarinho

Copy link
Copy Markdown
Contributor Author

Applied the /review-pr remediation (7 parallel critics, findings deduped and verified — 5 false positives rejected after checking against the actual diff/behavior). Notable: bin now ships a bun-bundled dist/cli.js so the private @decocms/shared dep is inlined at build time (no vendoring, no runtime deps); gh calls are batched (one per refresh instead of one per domain); error paths hardened (finally-cleanup, per-domain tick isolation, TUI degradation). The bun.lock drift beyond this package is a legit sync: main's committed lock is stale vs its own package.json versions (e.g. apps/api 4.122.3 in lock vs 4.122.12+ in package.json) — bun install re-synced it.

@pedrofrxncx

Copy link
Copy Markdown
Collaborator

Compared this against our studio-bot (the maintenance-PR bot that's been running on a cron for a while now, ~81% merge rate over 220+ PRs). The declaration + red-team lint idea is genuinely good — better than what we have — and using GitHub as the only state is leaner than our JSONL store. A few suggestions ranked by how hard we learned each lesson running ours:

1. Don't let the agent open the PR — the CLI should, after deterministic checks. The reconcile prompt says "stay strictly inside the declared scope" and "0 or 1 PR", but enforcement is prose: the agent runs with --permission-mode bypassPermissions and does gh pr create itself. The declaration gets red-teamed; the runner doesn't. Our pattern: the session ends at branch pushed + pr-spec written, and the orchestrator runs deterministic gates before gh pr create — here that's ~20 lines in run(): git diff origin/<default>...branch --name-only ⊆ declared scope globs, diff-size cap, no lockfile/dep churn, no new suppression comments (@ts-ignore, biome-ignore, oxlint-disable, …). A lazy agent that "fixes" a check by suppressing it currently sails straight to a PR — prompt rules are not guardrails.

2. The lock has a gap, and crashed runs leak "running" state forever. The open-PR lock only engages after the agent opens a PR; a run takes 10–30 min, so a second tick firing meanwhile starts a duplicate reconcile of the same domain. Separately, runningDomains() infers running from $TMPDIR/loop-<domain>-* existing, but SIGKILL/sleep/crash skips the finally cleanup — a dead run shows ● yes in status forever, and stale git worktree entries accumulate (nothing calls git worktree prune). Exactly this (SIGKILL skipping cleanup) once wedged our bot for hours. Fix we landed: write a PID file into the lock/worktree, steal it if the owner PID is dead, mtime age cap as fallback — ~15 lines, closes both holes.

3. One failing domain starves the rest of tick. sh() throws on any git/gh failure and tick()'s loop has no try/catch — domain #1 hitting a transient gh error means domains #2..N never reconcile that tick, silently, on a cron nobody watches. Per-domain try/catch + log line.

4. The Tools section claims to be "a permission boundary" — it isn't. With bypassPermissions the allowlist is advisory text. Either wire it into --allowedTools (or a generated .claude/settings.json in the worktree), or reword the template so owners don't think they've sandboxed something they haven't.

5. Surface unprocessed rejections in status. "A rejected PR must become a declaration edit" is the compounding mechanism of the whole design, but it's pure discipline. status already queries PRs — also listing closed-unmerged loop:<domain> PRs newer than the declaration's last commit ("rejected, declaration unchanged") makes the contract visible. In our bot, closed-unmerged PRs turned out to be the single highest-value learning signal (it's how we found that every one of our 37 closed PRs was a coverage-test PR).

6. Three copies of SKILL.md. .cursor/skills/domain-lint/, packages/loop/skills/domain-lint/, plus the copy init installs to .claude/skills/ — and lint reads the packaged one while the repo carries the others, so they'll drift. One source, copied at init/build.

1–3 are the ones I'd actually push on before this runs unattended; 4–6 take or leave.

bun stays a build-time tool only: build.ts bundles to dist/cli.js with
--target node and a node shebang. Verified under plain node v23.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants