Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions INTRO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Why This Exists

There's a particular kind of loneliness to maintaining software. Not the work itself — the work can be absorbing. The loneliness is in the surrounding quiet: the 2 AM test failures nobody sees, the dependency bumps that matter but don't merit a conversation, the steady accumulation of small tasks that never rise to the level of interesting but still need a human to sit down, read the context, create a branch, write the change, run the tests, fix what broke, push, open a PR, and describe what happened. Every single time.

AI can write code. That stopped being impressive a while ago. What AI mostly can't do is *ship* code — navigate the real topography of a project, respect its conventions, operate inside its workflow, and produce something a teammate would actually merge. The gap between a code suggestion and a pull request is enormous, and it's almost entirely plumbing. But plumbing is what makes a building habitable.

ATS Project Runner closes that gap.

## What It Does

One file. Under a thousand lines of Node.js. Zero dependencies. It reads a task from an [ATS](https://github.com/difflabai) queue, matches it to a configured project, creates a branch, and hands the real repository to Claude Code. When the work is done: commit, push, pull request.

It has two modes. **CLI mode** for running a specific task by ID. **Watch mode** for subscribing to task channels via WebSocket and processing whatever arrives — you start it once and a backlog becomes a queue of pull requests.

The system never touches the original task. It creates a copy on a suffixed channel, and all the claiming, heartbeating, completing, and failing happens on that copy. The original stays untouched as a record of what was asked.

## How It Thinks

Not every task deserves the same effort. The runner reads the title and description and decides. A task that says "fix typo" gets a single pass — one invocation of Claude, done. A task that says "implement" or "refactor" gets up to 8 iterative passes, where Claude reviews its own previous work, runs the test suite, and refines. A long description — evidence that someone took the time to explain — earns more passes. A project with a test suite earns more still. Even a task with no keywords but a substantial description gets promoted to iterative, because length is its own kind of signal. The maximum is capped per project, and the whole heuristic can be overridden from the command line or the task payload.

This is the difference between generating code and doing work. Work involves looking at what you produced, deciding it's not good enough, and trying again. The iteration loop is a `for` statement — nothing exotic. But the behavior it produces is closer to how a careful person operates than any single-prompt tool manages.

## Multi-Attempt Memory

When configured for multiple attempts, each run is independent: separate branch, separate PR. But run 2 receives context about run 1 — which branch was created, which PR was opened, whether it succeeded or failed. Run 3 sees runs 1 and 2. Claude doesn't repeat the same approach; it genuinely diverges, building on what worked and abandoning what didn't.

This is useful for hard problems where you'd rather review three different solutions than stake everything on one. It's also useful for the specific humility of admitting that sometimes the best strategy is to try again differently.

## The Part That's Hard to Talk About

The runner is in its own config. Fourth entry, right there alongside the projects it serves:

```json
"ats-project-runner": {
"channel": "ats-project-runner",
"repo": "/path/to/ats-project-runner",
...
}
```

It can receive tasks to modify itself, execute them, and open PRs against its own repository. The tool that ships code can ship improvements to itself. This is written here not as a boast but as a statement of fact that still feels slightly uncanny. A thing that fixes itself is a different kind of thing.

## Under the Hood

The architecture is deliberately boring. A single Node.js file that shells out to four CLIs: `git` for version control, `gh` for pull requests, `claude` for the AI, and `ats` for the task queue. Telegram notifications so you know what happened while you slept. Structured JSON logging. Lease renewal heartbeats so tasks don't expire mid-run. Graceful shutdown handlers so a SIGTERM doesn't leave orphaned branches. If a watcher dies, it restarts itself after five seconds — not because the code is clever, but because the alternative is silence, and silence is the enemy of trust.

There are no abstractions beyond what the problem demands. No plugin system, no middleware, no configuration DSL. The value isn't in any one piece — it's in the quiet discipline of wiring them together simply enough that you can read the whole thing in one sitting and know exactly what it will do.

---

This is a tool for people who have more to build than they have hands for. It won't replace the thinking, the architecture, the hard decisions about what to build and why. But the mechanical work of turning a described task into a reviewed pull request — the part that's important but not interesting, necessary but not creative — that, it can do. Reliably, at 3 AM, while you sleep, without complaint.

You wake up to pull requests. They're not perfect — some need a second look, a nudge, a rethink. But they exist. The branch is there, the tests ran, the diff is waiting. And the quiet hours between midnight and morning, the ones that used to belong to no one, produced something. That's not automation. That's relief.
18 changes: 14 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,11 @@ async function processTask(task, project, projectName, runNumber, modeOverride,
let prompt;

if (i === 0) {
prompt = `You are working on the project at ${repoPath}. Execute this task:\n\n${title}\n${description}${prevContext}\n\nMake progress on this task. Do NOT commit, push, or create PRs.`;
prompt = `You are working on the project at ${repoPath}. Execute this task:\n\n${title}\n${description}${prevContext}\n\nThis is iteration 1 of ${iterations}. Make progress on this task. Do NOT commit, push, or create PRs.`;
} else if (i < iterations - 1) {
prompt = `Continue working on the task: ${title}. This is iteration ${i + 1} of ${iterations}. Review changes so far, improve quality, run tests if available, fix issues. You have ${iterations - i - 1} more iteration(s) after this — use them to refine and polish. Only respond with 'TASK_COMPLETE' if there is truly nothing left to improve. Do NOT commit, push, or create PRs.`;
} else {
prompt = `Continue working on the task: ${title}. Review changes so far, run tests if available, fix issues. When the task is fully complete and tests pass, respond with exactly 'TASK_COMPLETE' on its own line. Do NOT commit, push, or create PRs.`;
prompt = `Final iteration (${i + 1} of ${iterations}) for task: ${title}. Do a final review pass — check quality, fix any remaining issues, run tests. When satisfied, respond with 'TASK_COMPLETE' on its own line. Do NOT commit, push, or create PRs.`;
}

log('info', `Claude iteration ${i + 1}/${iterations}`, { runTaskId, mode });
Expand Down Expand Up @@ -756,14 +758,22 @@ function handleWatchEvent(event, projectName, channel, project) {
const title = event.title || 'Untitled';
log('info', 'Watch: new task detected', { taskId, title, channel, projectName });

// Determine attempts count
// Determine attempts count and mode override from payload
let attempts = project.default_attempts || 1;
let modeOverrideFromPayload = null;
if (event.payload) {
try {
const payload = typeof event.payload === 'string' ? JSON.parse(event.payload) : event.payload;
if (payload.attempts && Number.isInteger(payload.attempts) && payload.attempts > 0) {
attempts = payload.attempts;
}
if (payload.iterations || payload.mode) {
const mode = payload.mode || 'iterative';
const iterations = payload.iterations ? parseInt(payload.iterations, 10) : undefined;
if ((mode === 'oneshot' || mode === 'iterative') && (!iterations || iterations > 0)) {
modeOverrideFromPayload = { mode, iterations };
}
}
} catch {}
}

Expand All @@ -787,7 +797,7 @@ function handleWatchEvent(event, projectName, channel, project) {
project,
projectName,
attempts,
modeOverride: null,
modeOverride: modeOverrideFromPayload,
});

// Trigger queue processing
Expand Down