English | 日本語 | 简体中文 | Français
Loop X Engineering's mission is to give you back the time issue triage eats: a standing, unattended teammate that works your GitLab queue every weekday so nothing assigned to you sits untouched — shipping fixes, answering questions, or flagging what genuinely needs your judgment — and your attention goes only where it actually matters. A local web dashboard lets you watch it work, review everything it's done, and configure it all by hand — no editing JSON.
It's built to be safe to leave running unattended: it never merges its own merge requests, never assigns itself new issues, and only ever touches the projects you've explicitly told it about.
- How it works
- Loops
- Requirements
- Quick start
- Directory layout
- Configuration
- Running it
- The dashboard
- Connectors
- Scripts reference
- Safety boundaries
- Testing
- Project docs
- License
Each scheduled run (run-loop-now.sh gitlab-loop):
- Lists every open GitLab issue assigned to your configured username, across every project alias in your config.
- Processes them one at a time, never in parallel, following the step-by-step decision procedure in
LOOPX_INSTRUCTIONS.md. - For each issue, does exactly one of:
- Fix it — in an isolated git worktree, on a
loop/issue-<iid>branch, only opening a merge request once the project's own lint/test commands pass. - Answer it — post a GitLab comment when the ask needs no code change (a question, a status check).
- Escalate it — post a GitLab comment asking for clarification when the ask is ambiguous, or when verification fails.
- Sends a Slack message per issue plus one end-of-run digest (every run, even mornings with nothing assigned).
- Updates
PROGRESS.mdandoutputs/daily-review.mdso the next run — and you — know what happened.
Reusable, cross-run lessons (fix patterns, gotchas) get recorded per issue as markdown task-memory files via bin/memory_store.py (entries recorded before this format existed are still read via bin/project_memory.py), so later runs start smarter than the last.
A second, independent loop (run-loop-now.sh topic-loop) watches arbitrary topics on the wider web instead of GitLab — see docs/tasks/topic-monitor-loop.md.
A third loop (run-loop-now.sh inbox-triage-loop) triages Gmail and Outlook inboxes: it categorises each new unread message into a Loop/* label, drafts (never sends) a threaded reply to anything urgent, and reports via a Slack digest and the dashboard's Loops → Inbox Triage page — see docs/tasks/inbox-triage-loop.md.
Seven loops ship in config/loops.json.template; each has its own page under Loops in the dashboard and its own spec under docs/tasks/. A loop's schedule is editable on that page; the defaults below come from the template.
| Loop | What it does | Needs | Default schedule | Writes outside Loop X |
|---|---|---|---|---|
| GitLab issues | Works your assigned issues: fixes, answers or escalates each one | GitLab config (~/.gitlab/config.json) |
Weekdays 10:00 | Branches and merge requests (never merged), issue comments, Slack |
| Topic monitor | Researches your topics on the web and sends a daily briefing | Topics (topics.json) |
Daily 10:00 | Slack digest |
| Inbox triage | Labels new mail and drafts replies to urgent messages (disabled by default) | A mailbox (mail capability) |
Weekdays 09:00 | Mail labels and drafts (never sends) |
| Daily Digest | One morning brief: todos, assigned issues, MRs awaiting your review, today's meetings and what Loop X did yesterday (disabled by default) | issues connector (calendar optional) |
Weekdays 09:30 | Notifications only |
| MR Review | Pre-reviews merge requests where you are a reviewer (disabled by default) | merge_requests connector (GitLab) |
Every 2 hours | GitLab draft notes only; never publishes, approves or posts a normal note |
| Pipeline Doctor | Diagnoses failed CI pipelines on tracked projects and your open MRs, and flags recurring failures (disabled by default) | pipelines connector (GitLab) |
Hourly | Notifications only |
| RSS Watch | Ranks new feed entries against your interests and sends a short digest (disabled by default) | feed connector (RSS) |
Daily 08:00 | Notifications only |
The last four are LoopKit plugins (bin/loopkit.py, bin/loop_plugins/): they run the model sealed (no tools, no MCP servers), isolate each item so one failure never stops the run, and send through the loop's Notify via connectors. See docs/architecture.md.
- macOS (the schedule and the dashboard both run as
launchdagents) - Python 3.12+ — this repo's own code is stdlib-only, no
pip installneeded to run it git2.42+ (worktrees, push-options)- A GitLab account + personal access token for the projects you want tracked
- (optional) A Slack incoming webhook, for run notifications
- The
[gitlab-config](https://github.com/encoreshao/encore-skills/tree/main/skills/gitlab-config)skill from[encore-skills](https://github.com/encoreshao/encore-skills)— this loop's one external dependency, deployed to~/.encore-skillsbysetup.sh. Check it's actually present any time from the dashboard's Settings → Skills page. pytest— dev-only, for running this repo's own test suite
curl -fsSL https://raw.githubusercontent.com/encoreshao/loop-engineering/main/bin/scripts/install.sh | bashClones this repo into ~/.loop-engineering (pass --dir <path> for somewhere else) and runs bin/scripts/setup.sh, which installs the gitlab-config skill and scaffolds projects.json/topics.json from their templates. It then sets up the local nginx reverse proxy and starts the dashboard as an always-on launchd agent, so this one command ends with the dashboard actually reachable and running — pass --skip-nginx and/or --skip-launchd-daemons to opt out of either. (The scheduled GitLab loop and topic monitor are not auto-started, since they'd act on projects.json/topics.json before you've filled them in — start those yourself, once configured, from the dashboard's Settings → Daemons page.) Re-running the same command later just pulls the latest main instead of re-cloning.
Already installed and just want to update? Add --upgrade:
curl -fsSL https://raw.githubusercontent.com/encoreshao/loop-engineering/main/bin/scripts/install.sh | bash -s -- --upgradeSame steps as above, but fails fast if nothing's installed at --dir yet instead of silently cloning fresh, and refreshes every one of this project's launchd agents that's currently loaded — not just the dashboard. The dashboard (an always-on server) gets an actual restart (launchctl kickstart -k), unlike a bare launchctl load, which is a no-op on an already-running agent. com.hermes.loop-engineering — the single scheduler that runs every loop registered in loops.json, if you've enabled it from the Settings → Daemons page — just gets its registration reloaded (unload + load -w) — never kickstarted, since that would trigger a real, out-of-schedule run against live GitLab/Slack right now rather than waiting for the scheduler's own next poll. --upgrade also migrates a pre-existing rendered plist left over from before the unified scheduler (one that still points at the now-deleted run-loop.sh) and removes the now-orphaned com.hermes.loop-engineering-topic-monitor daemon if it's still installed from before that migration.
Prefer to see the clone happen yourself first?
git clone https://github.com/encoreshao/loop-engineering.git
cd loop-engineering
bin/scripts/setup.shAlready have the skill installed and just want the config scaffolds?
bin/scripts/setup.sh --skip-skills-installOnce it's done, open the dashboard's Settings → Skills page to confirm everything needed is actually installed — it checks live, no guesswork.
Working in Claude Code already? Paste this instead of running the commands yourself:
Clone and set up https://github.com/encoreshao/loop-engineering for me: run its online installer (
curl -fsSL https://raw.githubusercontent.com/encoreshao/loop-engineering/main/bin/scripts/install.sh | bash), then help me fill in~/.loop-engineering/projects.jsonwith my own GitLab project(s), and~/.gitlab/config.jsonwith my GitLab token.
bin/scripts/uninstall.sh # or: curl -fsSL .../uninstall.sh | bashUnloads and removes this repo's launchd agents, reverses setup-nginx.sh if you ran it, and removes the whole ~/.loop-engineering folder — code, config, and run history together — pass --keep-config to leave it all in place instead (e.g. you're about to reinstall). Safe to re-run.
Using the default install path, everything lands under one folder:
~/.loop-engineering/ # install.sh's clone target
├── bin/, docs/, tests/, ... # this repo's own code (tracked in git)
├── projects.json # your config: GitLab projects to track ┐
├── topics.json # your config: topics to monitor │
├── loops.json # your config: scheduled-loop registry ├─ gitignored, yours
├── instructions.md # your free-text instructions │
├── connectors.json # your config: connector accounts │
├── ai_cli.json # your config: Claude Code vs Codex CLI ┘
├── loop_scheduler_state.json # managed automatically, not hand-edited
├── PROGRESS.md # live run state, updated every run
├── outputs/ # ← generated docs & run history live here (gitignored)
│ ├── daily-review.md # latest GitLab-issue-loop report
│ ├── connectors/test-results.json # last Test result per connector account
│ ├── messages.json # Dashboard → Activity message thread
│ ├── status.json # GitLab loop's current/last run status
│ ├── status/<loop_name>.json # every other registered loop's current/last run status
│ └── history/<date>.{md,log} # every past run's report + log
└── worktrees/ # ← per-issue git worktrees for tracked projects (gitignored)
└── <project>-issue-<iid>/ # that project's own checkout, on branch loop/issue-<iid>
projects.json, topics.json, loops.json, instructions.md, and ai_cli.json always resolve to ~/.loop-engineering/… regardless of where you clone the code — they only end up inside the repo folder above because install.sh's default clone target happens to be that same path. If you clone somewhere else by hand, those five files still live at ~/.loop-engineering/, separate from the code. projects.json's scaffolded worktree_root defaults to ~/.loop-engineering/worktrees too, for the same reason.
Two more config files live outside this tree entirely, editable from the dashboard's Loops → GitLab Issues → Projects and Settings → Notifications pages instead of by hand: ~/.gitlab/config.json and ~/.slack/config.json.
| File | Holds | Managed via |
|---|---|---|
~/.loop-engineering/projects.json |
Which projects to track, their local checkout paths, target branch, install/lint/test commands, your GitLab username, and the worktree scratch directory (worktree_root, defaults to ~/.loop-engineering/worktrees) |
Dashboard Loops → GitLab Issues → Projects page's "Tracked Projects" section, or copy config/projects.json.template by hand, or let bin/scripts/setup.sh do it |
↳ per-project instance (optional) |
Overrides the top-level gitlab_instance for one project — set this when your projects span more than one GitLab instance. Falls back to gitlab_instance when omitted. |
Same file, per project entry — see the template's harbor example |
~/.loop-engineering/topics.json |
Which topics to monitor and what counts as notable for each one (topic monitor loop only) | Copy config/topics.json.template by hand, or let bin/scripts/setup.sh do it |
~/.loop-engineering/inboxes.json |
Which mailboxes to triage (provider, account, categories, VIP/excluded senders, Slack bundle) and the shared default category set (Inbox Triage loop only) | Dashboard's Loops → Inbox Triage → Setup (/loops/inbox-triage-loop?view=setup), or let bin/scripts/setup.sh scaffold it from config/inboxes.json.template |
~/.loop-engineering/mail_oauth.json |
The Gmail/Outlook OAuth app's own client ID (and, for Google, client secret) — a one-time app-registration step, not a per-mailbox credential | Dashboard's Loops → Inbox Triage → Setup page |
~/.loop-engineering/loops.json |
The registry of scheduled loops: each entry's name, schedule (weekdays/hour/minute), entry point module, timeout, and per-loop knobs — read by bin/loops_config.py, polled by bin/loop_scheduler.py |
Copy config/loops.json.template by hand, or let bin/scripts/setup.sh do it |
~/.loop-engineering/loop_scheduler_state.json |
Per-loop last-attempted date, so the scheduler never runs the same loop twice in a day — not something you hand-edit | Written automatically by bin/loop_scheduler.py; seeded with today's date for every registered loop by bin/scripts/setup.sh so enabling the scheduler doesn't fire an immediate run |
~/.loop-engineering/instructions.md |
Your own free-text instructions, read by the loop at the start of every run | Dashboard Settings page's Instructions tab |
~/.loop-engineering/ai_cli.json |
Which AI CLI (Claude Code or Codex CLI) run-loop-now.sh invokes for every registered loop; defaults to claude |
Dashboard Settings page's AI CLI tab, or let bin/scripts/setup.sh do it |
~/.gitlab/config.json |
GitLab instance URLs, tokens, and project-alias → project-ID mappings (read by the gitlab-config skill) |
Dashboard Loops → GitLab Issues → Projects page |
~/.slack/config.json |
Your Slack incoming webhook URL (and any per-bundle overrides) | Dashboard Settings page's Notifications tab (the default webhook) / Loops → GitLab Issues → Projects page's Access bundles section (per-bundle overrides) |
bin/loop_config.py is the only code that reads projects.json — use it to sanity-check your config from a terminal:
python3 bin/loop_config.py aliases # every configured project alias
python3 bin/loop_config.py project <alias> # that alias's full config, incl. resolved GitLab instance
python3 bin/loop_config.py assignee # the GitLab username being tracked
python3 bin/loop_config.py worktree-root # where per-issue worktrees get createdIf ~/.loop-engineering/projects.json doesn't exist yet, every script that needs it fails fast with a message telling you to run bin/scripts/setup.sh — nothing silently guesses paths.
Access bundles — per-project token/webhook overrides
Most projects just use their GitLab instance's default token. An access bundle is a named override — its own {instance, token} pair, plus an optional Slack webhook — for the rare project whose default instance token doesn't have the access that project needs.
Manage bundles from the dashboard's Loops → GitLab Issues → Projects page, in their own "Access bundles" section:
- Add a bundle: name it, pick which GitLab instance it authenticates against, paste its token, and optionally a Slack webhook URL.
- Assign a bundle to a project: edit the project alias's row and pick the bundle from the Bundle dropdown — defaults to "(use instance default)".
- A bundle can't be deleted, and its instance can't be changed, while any project alias still points at it.
- Deleting a bundle also clears its Slack webhook override, if it had one.
Bundles live in ~/.gitlab/config.json's bundles key and, if a webhook override is set, ~/.slack/config.json's bundle_webhooks key — joined only by the bundle's name.
Manually, once, to see it work before trusting it with a schedule:
bash run-loop-now.sh gitlab-loop # the daily GitLab issue loop
bash run-loop-now.sh topic-loop # the topic monitor loopBoth log to outputs/history/, and both also append every claude CLI invocation's output to logs/loop-engineering.log (viewable on the dashboard's Runs → Logs page); you can also trigger the GitLab loop from the dashboard's Run now button (Dashboard → Overview) without a terminal.
On a schedule, via launchd — install the two agents under launchd/, most easily with a click each from the dashboard's Settings → Daemons page (which also shows whether each is currently loaded and its PID), or by hand:
cp launchd/com.hermes.loop-engineering*.plist ~/Library/LaunchAgents/
launchctl load -w ~/Library/LaunchAgents/com.hermes.loop-engineering.plist
launchctl load -w ~/Library/LaunchAgents/com.hermes.loop-engineering-dashboard.plist| Agent | Runs |
|---|---|
com.hermes.loop-engineering |
The single scheduler poll loop (bin/loop_scheduler.py), every 15 minutes (StartInterval) — runs whichever loop(s) registered in ~/.loop-engineering/loops.json are due, via run-loop-now.sh |
com.hermes.loop-engineering-dashboard |
The web dashboard, always-on (RunAtLoad + KeepAlive) |
Which loops run and on what schedule is config, not code — edit ~/.loop-engineering/loops.json (see config/loops.json.template) to add a loop or change when it's due; adding a third loop needs a new loops.json entry, not a new plist. The Settings → Daemons page's per-agent schedule editor only applies to a plist's own StartCalendarInterval, which com.hermes.loop-engineering no longer has (it polls every 15 minutes on a fixed StartInterval and defers to loops.json for which loop is actually due) — editing a loop's own schedule is a hand-edit of loops.json for now.
A localhost-only, dependency-free (stdlib Python, no JS framework) web UI, served by bin/web/dashboard_server.py. Running it directly for local dev (no arguments) uses its own default port, 8420. bin/scripts/install.sh picks a random port in 48420-48620 the first time it installs the always-on launchd agent (overridable with --port, and never re-picked on a later --upgrade) — check launchd/com.hermes.loop-engineering-dashboard.plist for the port an existing install is actually running on.
| Sidebar entry | Shows |
|---|---|
Dashboard (/) |
Views: Overview — Current/last run status, a live progress indicator, and the Run now button; Activity — A message thread with the loop, plus its own live progress indicator. Paste a GitLab issue link here to have the loop work on that one issue immediately, regardless of who it's assigned to. |
Loops (/loops) |
A catalog splitting active loops from available ones; each loop that's visible also appears as a child link under Loops in the sidebar (Inbox Triage stays hidden there while it's disabled and has never run) |
— GitLab Issues (/loops/gitlab-loop) |
Views: Live — Your currently assigned issues and open MRs, fetched live; Projects — Manage ~/.gitlab/config.json (instances, project aliases, access bundles) and ~/.loop-engineering/projects.json (tracked projects, loop settings) without hand-editing JSON |
— Topic Monitor (/loops/topic-loop) |
Views: Live — Status and saved briefings for every configured topic; Topics — Add, edit, and delete monitored topics — a separate view on the same page, so configuration doesn't clutter the Live status view |
— Inbox Triage (/loops/inbox-triage-loop) |
Views: Inbox Triage's own live status (Live) plus mailbox connection and categories (Setup: connect Gmail/Outlook, categories, VIP/excluded senders, Slack bundle) |
Runs (/runs) |
Views: Loop Runs — Every run recorded under outputs/loop-runs/ (one per issue or topic processed), most recent first — read-only; an overview strip shows total runs, success/escalation rate, average cost, and the experimental Loop Efficiency Score; History — Every past run's review report, newest first; Logs — The tail of logs/loop-engineering.log - every claude CLI invocation's output, across the GitLab loop, the topic monitor loop, and this dashboard's own chat assistant |
Insights (/insights) |
Views: Analytics — The loop's performance over a selectable day window: a Loop Health score, outcomes, quality, risk & classification, failure breakdown, and learning trends; Cost — AI usage cost — the GitLab issue loop's own windowed cost, and total cost across every run under outputs/loop-runs/; Budget — Each recorded run's last-known budget status, plus rollups by loop definition and by day/week/month; Memory — Cross-run lessons recorded per project, one markdown file per GitLab issue, plus anything recorded before this format existed (shown under "Legacy learnings") |
Harness (/harness) |
Views: Audit — A score and pass/fail checks for each loop definition |
Connectors (/connectors) |
Views: Accounts — Every connector account grouped by type, with capability chips, a Test button (Send test message for notification targets) showing its last result, and a "Managed on …" badge linking to the owner page for external accounts; Add — Pick a type and fill its form. Secrets go in a password field and are never shown again (leave blank to keep the stored one when editing) |
Settings (/settings) |
Views: General — Notifications (manage ~/.slack/config.json's default webhook), AI CLI (choose Claude Code or Codex CLI, with a live installed/not-found check for each), Appearance (color mode, accent theme, auto-refresh interval — saved to this browser's localStorage), and Instructions (your own free-text instructions, read by the loop at the start of every run) — clustered as tabs on one page (the General view is split into Notifications / AI CLI / Appearance / Instructions tabs (?tab=)); Daemons — Load state, an editable schedule, and enable/disable for every launchd agent, plus a Registered Loops breakdown of every loop the unified scheduler runs (its own schedule and last-run status, read from loops.json); Skills — Every external skill this loop depends on, and whether it's actually installed |
README (/readme) |
Moved to the topbar's help icon (/readme): this file, rendered in-app with a jump-to-section quicknav |
Every older URL (/activity, /gitlab, /topic-monitor, /inbox, /loop-runs, /history, /logs, /analytics, /cost, /budget, /memory, /audit, /settings/general, /daemons, /skills, and so on) permanently redirects (301) to its new home, query string kept, so bookmarks keep working.
Optional: a friendly hostname via nginx
By default the dashboard is only reachable at http://127.0.0.1:<port> (see above for how <port> is chosen). bin/scripts/setup-nginx.sh sets up a local nginx reverse proxy so it's reachable at http://loop.x/ (port 80) instead — installs nginx via Homebrew if needed, writes the proxy config, adds loop.x to /etc/hosts, and starts nginx as a system service. install.sh already passes it the installed port automatically; idempotent, safe to re-run standalone too:
bin/scripts/setup-nginx.sh
# or, with no clone at all:
curl -fsSL https://raw.githubusercontent.com/encoreshao/loop-engineering/main/bin/scripts/setup-nginx.sh | bashWriting /etc/hosts and starting the nginx service both need sudo — macOS will prompt for your password at those two steps. Pass --domain/--port to use something other than loop.x/8420.
A connector is an account the loops can talk to: a GitLab or GitHub instance, a Slack, Telegram or chat webhook, a Notion workspace, an RSS feed list, a Jira or Linear workspace, a mailbox, a Google Calendar. Manage them on the dashboard's System → Connectors page (/connectors). Each type declares capabilities (issues, merge_requests, pipelines, notify, feed, mail, docs, calendar), and a loop can require a capability instead of a specific product.
| Type | Capabilities | What you enter | Secret |
|---|---|---|---|
| GitLab | issues, merge_requests, pipelines |
URL | personal access token |
| GitHub | issues, merge_requests, pipelines |
API URL (default https://api.github.com), username |
token |
| Slack webhook | notify |
— | webhook URL |
| Chat webhook | notify |
pick a preset: Feishu, DingTalk, WeCom 企业微信 (group bot; personal WeChat has no bot API), Microsoft Teams, Discord, Google Chat, or Generic webhook | webhook URL |
| Telegram bot | notify |
chat ID | bot token |
| RSS / Atom feeds | feed |
feed URLs, one per line | — |
| Notion | docs (shown as Documents) |
— | integration token |
| Jira Cloud | issues |
site URL, email | API token |
| Linear | issues |
— | API key |
| Mailbox | mail |
external — managed on Inbox Triage setup | — |
| Google Calendar | calendar (shown as Calendar) |
calendar ID (default primary) |
Google sign-in (read-only, calendar.readonly); the refresh token goes in the Keychain |
Gallery and form.
- Add opens a gallery of connector types, grouped into Google (first: Gmail, Google Calendar and Google Chat), Code hosting, Chat & notifications, Work tracking, Knowledge, Feeds and Mail (Outlook lives here), with a search box to filter them. Cards follow the accent selected in Settings → Appearance and each service's brand color, and each chat webhook preset has its own description.
- Each tile and each account row carries the service's brand logo (inline Simple Icons marks; services without one — Feishu, DingTalk, the generic webhook — get a lettermark).
- The chat webhook tile expands into the presets above, each with a one-line hint (for example, Teams Workflows webhooks may require Adaptive Cards) and a Where do I get this? link to that service's own docs.
- The add/edit form has an Account section (Label and Connector id; the id is suggested from the label until you edit it), a Connection section (the type's settings) and a Credentials section (the secret).
- Google Calendar has no pasted secret: click Connect with Google (Reconnect once connected) to sign in. It reuses the Google OAuth client you already set up for Gmail on Inbox Triage's setup page (Gmail tab; if it is missing, the form links you there), so Google Cloud needs the same redirect URI,
http://127.0.0.1:<port>/oauth/google/callback. The account row shows a Connected / Not connected marker, and Test reads the calendar. Only the read-onlycalendar.readonlyscope is requested. - Required fields are marked
*, the others say "(optional)", and fields have example placeholders. The secret field has a Show/Hide toggle. - Buttons: Save, Save and test (saves, then runs the probe) and Cancel. If a save fails, the form is shown again with your non-secret values kept.
Where things live. Accounts you add on the page are native: their non-secret settings go in ~/.loop-engineering/connectors.json, and their secrets go in the macOS Keychain under the service loop-engineering.connectors (suffixed .sandbox-<hash> whenever LOOP_ENGINEERING_HOME is set, so a sandboxed run never touches the real ones). Secrets are never written to connectors.json and never shown again after saving.
External accounts are read through from the files that already own them, with nothing migrated: GitLab instances from ~/.gitlab/config.json (id = the instance alias), Slack webhooks from ~/.slack/config.json (slack-default, plus slack-<bundle> per bundle webhook), and mailboxes from inboxes.json (id = the inbox name). They show a "Managed on …" badge linking to the page where you edit them; you can still Test them here.
Test buttons. Every account has a Test button (Send test message for Slack, Telegram and chat webhooks); the last result is kept in outputs/connectors/test-results.json.
Loops and notifications. On Loops, a loop that needs a capability shows "Needs: …" chips, and it cannot be enabled (in the UI or by the server) until a connector with that capability exists. A loop whose loops.json entry declares "routes_notifications": true (its runner sends through bin/notify.py) also gets a Notify via selection, stored as notify: [connector ids]. bin/notify.py routes such a loop's notification to those connectors; with no notify set it posts to the default Slack webhook exactly as before. The built-in GitLab, Topic and Inbox loops don't declare it yet and still post to the Slack webhook directly; if one of them already has a notify list, Loops shows it read-only with a Clear button. You can try it from the CLI with python3 bin/notify.py <loop> "<text>". The dashboard's AI panel can also list your connectors (chat tool connector-list).
Expand for the full list
| Script | Purpose |
|---|---|
run-loop-now.sh |
Generic entry point for one registered loop's run (looked up from ~/.loop-engineering/loops.json via bin/loops_config.py) — logs to outputs/history/, notifies Slack on failure. Invoked by bin/loop_scheduler.py (on schedule) or the dashboard (on demand) |
bin/loop_scheduler.py |
The single launchd-scheduled poll loop: reads ~/.loop-engineering/loops.json and runs whichever registered loop(s) are due, via run-loop-now.sh |
bin/loops_config.py |
Reads ~/.loop-engineering/loops.json — the registry of scheduled loops (name, schedule, entry point); no write path today, hand-edit the file (or copy the template) to change it |
bin/gitlab_loop_runner.py |
The per-issue orchestrator run-loop-now.sh delegates to when running gitlab-loop: discovers assigned issues, runs each one through its own LoopRuntime (one LoopResult per issue under outputs/loop-runs/), owns the claude -p/codex exec invocation and its --allowedTools/--disallowedTools safety boundary, then runs one unconditional end-of-run wrap-up for the whole batch |
bin/scripts/build_run_prompt.sh |
Builds the prompt string bin/gitlab_loop_runner.py hands to the AI CLI — a single-issue prompt for <alias> <issue_iid> (the dashboard's Dashboard → Activity chat scoped run), --batch-issue <alias> <issue_iid> for one issue inside a scheduled batch (no end-of-run), and --batch-end-of-run for the batch's one digest/daily-review wrap-up |
bin/web/dashboard_server.py |
The web dashboard; also a small CLI (write-status, write-skills-install-status, read-messages, add-message, chat-tool) used by run-loop-now.sh, bin/loop_scheduler.py, the dashboard's own actions, and the Dashboard → Activity view's embedded chat assistant |
bin/loop_config.py |
Reads ~/.loop-engineering/projects.json |
bin/list_assigned_issues.py |
Lists open GitLab issues assigned to the configured user across configured projects |
bin/track_new_comments.py |
Detects which notes on a cached issue are new since the loop last looked |
bin/project_memory.py |
Reads (legacy) durable per-project lessons learned, stored inline in the GitLab cache |
bin/memory_store.py |
Reads/records durable per-issue task memory as markdown files (one per issue, plus a per-project MEMORY.md index) |
bin/ai_cli_config.py |
Reads/writes ~/.loop-engineering/ai_cli.json — which AI CLI (claude or codex) run-loop-now.sh invokes for every registered loop |
bin/topic_monitor_runner.py |
The per-topic orchestrator run-loop-now.sh delegates to when running topic-loop: runs each configured topic through its own LoopRuntime (one LoopResult per topic under outputs/loop-runs/), owns the claude -p/codex exec invocation and its safety boundary — same role for the topic monitor loop as bin/gitlab_loop_runner.py plays for the GitLab loop |
bin/scripts/build_topic_prompt.sh |
Builds the prompt string for one configured topic, same role as build_run_prompt.sh above; kept as a documented manual escape hatch even though topic_monitor_runner.py no longer calls it |
bin/topic_config.py |
Reads ~/.loop-engineering/topics.json |
bin/topic_seen.py |
Rolling 7-day dedup window per topic, so briefings don't repeat the same story two days running |
bin/slack_notify.py |
Posts a message to the configured Slack incoming webhook |
bin/scripts/new_worktree.sh |
Creates (or reuses) an isolated git worktree on a loop/issue-<iid> branch |
bin/scripts/open_merge_request.sh |
Pushes an issue branch and opens its MR — refuses anything not named loop/issue-* |
bin/scripts/install.sh |
Online installer — clones (or updates) this repo, then runs setup.sh (forwarding --config-path/--topics-config-path/--ai-cli-config-path/--loops-config-path/--state-path through to it); --upgrade for an existing install, refreshing every currently-loaded launchd agent (dashboard restarted, scheduler daemon just re-registered) so they pick up the new code; also migrates a stale pre-unified-scheduler com.hermes.loop-engineering.plist in place and removes the old, now-orphaned com.hermes.loop-engineering-topic-monitor daemon if still installed from before the unified scheduler; safe to pipe from curl |
bin/scripts/setup.sh |
One-command install: the gitlab-config skill + the projects.json/topics.json/ai_cli.json/loops.json scaffolds, plus a loop_scheduler_state.json seeded with today's date for every registered loop so enabling the scheduler right after install doesn't fire an immediate run |
bin/scripts/setup-nginx.sh |
Optional local nginx reverse proxy (http://loop.x/ → the dashboard) |
bin/scripts/uninstall.sh |
Reverses setup.sh/setup-nginx.sh/install.sh; safe to pipe from curl |
Fixed, and does not loosen with time or repeated success (see docs/tasks/gitlab-issue-loop.md):
- Never merges a merge request. The loop's job ends at "MR opened, verification passing" — merging is always a manual human step.
- Every code change happens in its own git worktree, on a
loop/issue-<iid>branch, never on the target branch directly. - An MR only opens if the project's own configured
test_cmd/lint_cmdpass, and the diff only touches files relevant to the issue. - No arbitrary shell, no dependency upgrades, no reading
.env/credentials/SSH keys — only the command allow-list inLOOPX_INSTRUCTIONS.md. - Issues are processed one at a time, sequentially, never in parallel.
- A verification failure on the same issue is never retried within a run — it escalates via a GitLab comment instead. (With
verification.mode: gatethe loop itself re-runs the project's checks and allows one bounded retry with the failing output as feedback; it never opens an MR whose tests/lint fail, and escalates with theloop:needs-humanlabel instead.)
The Inbox Triage loop has its own fixed safety boundary (see docs/tasks/inbox-triage-loop.md):
- Never sends mail. Neither mail provider module contains a send function, and the Outlook token it obtains is scoped without
Mail.Send— sending is impossible at the token level, not just the code level. - Never archives, deletes, moves, or changes read state. The only mailbox writes are creating
Loop/*labels/categories, applying them, and creating reply drafts left in the mailbox's own Drafts folder. - Only
Loop/*labels are ever applied. Every category label, default or custom, must start withLoop/—inboxes.jsonis rejected on load otherwise, so a hand-edited system label likeTRASHorUNREADcan never be applied to real mail. - Message bodies never persist. They exist only in memory and in the prompt of the
claude -pcall per inbox (plus at most one retry), which keeps no session transcript — never written tooutputs/, logs, status, or the Slack digest, which only ever get sender, subject, category, the AI's short reason, and a draft link. The AI-written reply draft is saved only in the mailbox's own Drafts folder. - Inbox Triage requires the Claude CLI. Codex always gives the model a shell and records the prompt under
~/.codex/sessions/, so with Codex selected every inbox fails up front — before any mail is read — until the AI CLI is switched back to Claude in Settings. - Refresh tokens live only in the macOS Keychain, written via
security -iwith the token on stdin, never on disk in plain text or in a process's argv.
python3 -m pytest tests/Every script under bin/ (Python or shell, whichever folder it lives in) has a matching tests/test_*.py, exercised against real subprocesses/tmp dirs rather than mocks wherever practical (see tests/test_new_worktree.py for an example using a real local git repo).
| Doc | What it's for |
|---|---|
docs/architecture.md |
The V2 runtime architecture: LoopDefinition/LoopState/LoopRuntime, verification/budget/policy, observability, and the CLI — the map, not either loop's own spec |
TASK.md |
Index of every scheduled task this repo runs, each pointing at its own spec under docs/tasks/ |
docs/tasks/gitlab-issue-loop.md |
The GitLab issue loop's human-facing spec: goal, scope, safety boundaries |
docs/tasks/topic-monitor-loop.md |
The topic monitor loop's human-facing spec: goal, scope, safety boundaries |
LOOPX_INSTRUCTIONS.md |
The step-by-step procedure the GitLab issue loop itself follows each run |
TOPIC_MONITOR_INSTRUCTIONS.md |
The step-by-step procedure the topic monitor loop itself follows each run |
PROGRESS.md |
Live state the loop reads and updates every run — last run's summary, open escalations, decisions made |
docs/troubleshooting/crash-looping-launchd-agent.md |
Diagnose and fix a com.hermes.loop-engineering* launchd agent stuck crash-looping and flooding its log |