awayteam 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/bin/awayteam.js +11 -0
- package/lib/adapters/claude.js +35 -0
- package/lib/adapters/codex.js +21 -0
- package/lib/adapters/index.js +18 -0
- package/lib/adapters/stub-agent.js +38 -0
- package/lib/adapters/stub.js +23 -0
- package/lib/board.js +140 -0
- package/lib/cli.js +173 -0
- package/lib/config.js +43 -0
- package/lib/notify.js +20 -0
- package/lib/proc.js +57 -0
- package/lib/ratelimit.js +21 -0
- package/lib/report.js +123 -0
- package/lib/status.js +52 -0
- package/lib/stopctl.js +46 -0
- package/lib/supervisor.js +183 -0
- package/lib/worktree.js +89 -0
- package/package.json +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Awayteam Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# awayteam
|
|
2
|
+
|
|
3
|
+
**Run a fleet of coding agents overnight. Wake up to a report.**
|
|
4
|
+
|
|
5
|
+
awayteam turns one machine into an overnight ops desk for AI coding agents: it works
|
|
6
|
+
through a plain-file task board, gives every task its own git worktree and branch,
|
|
7
|
+
survives rate-limit errors with backoff, and hands you a morning report of branches
|
|
8
|
+
ready for review. It uses the agent CLIs you already pay for — **Claude Code, Codex CLI**
|
|
9
|
+
— so overnight runs cost no more than your existing subscription.
|
|
10
|
+
|
|
11
|
+
Nothing is ever merged for you. Agents work on branches; you review.
|
|
12
|
+
|
|
13
|
+
```console
|
|
14
|
+
$ awayteam up --jobs 3 --window 8h
|
|
15
|
+
[23:00:14] w1 claimed 001-fix-login-crash.md
|
|
16
|
+
[23:00:15] w2 claimed 002-slug-dashes.md
|
|
17
|
+
[23:00:16] w3 claimed 003-words-empty.md
|
|
18
|
+
[23:01:10] w1 done 001-fix-login-crash.md (attempt 1) -> away/001-fix-login-crash
|
|
19
|
+
[23:04:47] w2 retry 002-slug-dashes.md attempt 2 next, backoff 4m # rate limit, handled
|
|
20
|
+
...
|
|
21
|
+
run complete - board boards/jobs (3 workers, claude) - done 6, failed 1, abandoned 0, queued 0
|
|
22
|
+
report: .awayteam/reports/2026-08-31.md
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
$ cd your-repo
|
|
29
|
+
$ npm install -g awayteam
|
|
30
|
+
$ awayteam init
|
|
31
|
+
$ awayteam up
|
|
32
|
+
$ awayteam report
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`init` detects which agent CLIs are installed, writes `awayteam.config.json`, and seeds a
|
|
36
|
+
demo board you can watch end-to-end before trusting it with real work.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
**The board is a folder.** Tasks are markdown files; directories are the state:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
boards/jobs/
|
|
44
|
+
todo/ <-> agents pick from here
|
|
45
|
+
in-progress/
|
|
46
|
+
done/
|
|
47
|
+
failed/
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```markdown
|
|
51
|
+
---
|
|
52
|
+
status: todo
|
|
53
|
+
---
|
|
54
|
+
slugify("a b") currently produces "a---b". Fix lib/slug.js so repeated
|
|
55
|
+
separators collapse to one dash. Add tests. Run `npm test`, then commit.
|
|
56
|
+
|
|
57
|
+
Commit message: 001: collapse repeated dashes in slugify
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Claiming a task is an atomic rename, so any number of parallel workers race the same
|
|
61
|
+
board safely — no locks, no database, and the board itself is diffable in git.
|
|
62
|
+
|
|
63
|
+
**Every task is isolated.** `awayteam` gives each worker its own git worktree and a
|
|
64
|
+
branch like `away/001-fix-login-crash`. A crashed or misbehaved agent cannot touch
|
|
65
|
+
your working tree or anyone else's work. There is no auto-merge and no remote push.
|
|
66
|
+
|
|
67
|
+
**The report is generated from git.** Commits, diffstats, the exact review command —
|
|
68
|
+
per task, plus the honest failures with their stderr.
|
|
69
|
+
|
|
70
|
+
```markdown
|
|
71
|
+
### 001-hello
|
|
72
|
+
branch `away/001-hello` - 1 attempt(s) - Done.
|
|
73
|
+
- `hello.txt` created with content `awayteam was here`
|
|
74
|
+
- Committed as `d97db6f awayteam smoke task` on `away/001-hello`
|
|
75
|
+
|
|
76
|
+
review: `git log -p fe61cf552..away/001-hello`
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Overnight recipe
|
|
80
|
+
|
|
81
|
+
Run it from a terminal that stays open (tmux), or schedule it:
|
|
82
|
+
|
|
83
|
+
```cron
|
|
84
|
+
# launchd/cron: nightly at 23:00, report waiting at 07:00
|
|
85
|
+
0 23 * * * cd /srv/myapi && /usr/local/bin/awayteam up --window 7h >> .awayteam/up.log 2>&1
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Cheap VMs work the same way — see the Ops Pack for ready-made cloud + notification setups.
|
|
89
|
+
|
|
90
|
+
## Safety model
|
|
91
|
+
|
|
92
|
+
- Agents are clones of your own CLI logins. No servers, no API keys stored by awayteam.
|
|
93
|
+
- Permission bypass is **off by default**; for overnight runs you either enable
|
|
94
|
+
`bypass_permissions` per repo or pre-approve allowed commands in your agent CLI's
|
|
95
|
+
own settings (recommended — e.g. Claude Code's `settings.json` allowlist).
|
|
96
|
+
- Work happens in throwaway worktrees on `away/*` branches. Merge only what you review.
|
|
97
|
+
- Rate limits: detected, chunked, retried with exponential backoff — so your fixed
|
|
98
|
+
subscription does the most work per dollar instead of dying at 2 AM.
|
|
99
|
+
|
|
100
|
+
## Commands
|
|
101
|
+
|
|
102
|
+
| command | what it does |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `awayteam init` | detect agent CLIs, write config, seed demo board |
|
|
105
|
+
| `awayteam up` | run the fleet (until board is empty or the window ends) |
|
|
106
|
+
| `awayteam status` | live view of the fleet from another terminal |
|
|
107
|
+
| `awayteam stop [--now]` | finish running tasks, then stop / kill now |
|
|
108
|
+
| `awayteam report` | render the latest morning report |
|
|
109
|
+
|
|
110
|
+
Flags: `--jobs N`, `--board DIR`, `--window 8h`, `--adapter claude|codex|stub`.
|
|
111
|
+
|
|
112
|
+
Notify hook: `notify_command` in `awayteam.config.json` receives run events as JSON on
|
|
113
|
+
stdin — point it at Slack, Telegram, or mail.
|
|
114
|
+
|
|
115
|
+
## Writing your own adapter
|
|
116
|
+
|
|
117
|
+
An adapter is ~20 lines: `{ cmdFor(task, cfg), parse(result) }`. See `lib/adapters/`
|
|
118
|
+
(`claude.js` and `codex.js` ship in the box).
|
|
119
|
+
|
|
120
|
+
## The Ops Pack (paid)
|
|
121
|
+
|
|
122
|
+
The free core above is the whole engine, unlimited. The Ops Pack adds curated job
|
|
123
|
+
boards and the ops glue: bug-sweep, test-gap, doc-sync and content-machine packs,
|
|
124
|
+
cloud VM provisioning recipes (Ubuntu, launchd/cron), notification integrations
|
|
125
|
+
(Slack/Telegram/mail), and the Operator's Runbook. [Ops Pack](https://gumroad.com/l/awayteam)
|
|
126
|
+
or [done-for-you setup](https://gumroad.com/l/awayteam-dfy).
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT.
|
package/bin/awayteam.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { main } = require('../lib/cli');
|
|
3
|
+
|
|
4
|
+
main(process.argv.slice(2))
|
|
5
|
+
.then((code) => {
|
|
6
|
+
process.exitCode = typeof code === 'number' ? code : 0;
|
|
7
|
+
})
|
|
8
|
+
.catch((err) => {
|
|
9
|
+
console.error(err && err.message ? `awayteam: ${err.message}` : String(err));
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// claude adapter — runs `claude -p` headless. The prompt (task body) goes in
|
|
2
|
+
// over stdin (task bodies can be long), results come back as one JSON line.
|
|
3
|
+
//
|
|
4
|
+
// Permission modes: `claude -p` headless sessions cannot ask interactive
|
|
5
|
+
// permission questions — without pre-approved permissions or the bypass flag
|
|
6
|
+
// the agent will stall or refuse. awayteam default is bypass OFF; see README
|
|
7
|
+
// "Safety" for the trade-offs before switching it on overnight.
|
|
8
|
+
function cmdFor(task, cfg) {
|
|
9
|
+
const args = ['-p', '--output-format', 'json'];
|
|
10
|
+
if (cfg.bypass_permissions) args.unshift('--dangerously-skip-permissions');
|
|
11
|
+
for (const a of cfg.claude_args || []) args.push(a);
|
|
12
|
+
return { cmd: ['claude', ...args], stdin: task.body + '\n' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// `claude -p --output-format json` emits one JSON object per line; the final
|
|
16
|
+
// line carries the run result. Scan backwards for the last parseable one.
|
|
17
|
+
function parse({ exitCode, stdout }) {
|
|
18
|
+
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
19
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
20
|
+
try {
|
|
21
|
+
const obj = JSON.parse(lines[i]);
|
|
22
|
+
if (typeof obj.result === 'string' || typeof obj.is_error === 'boolean') {
|
|
23
|
+
return {
|
|
24
|
+
ok: !obj.is_error && exitCode === 0,
|
|
25
|
+
summary: String(obj.result || '').trim().slice(0, 400),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
} catch (err) {
|
|
29
|
+
// not JSON — keep scanning backwards
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { ok: exitCode === 0, summary: stdout.trim().slice(0, 400) || '(no output)' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { name: 'claude', cmdFor, parse };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// codex adapter — runs `codex exec` headless. exec mode never asks for
|
|
2
|
+
// approval; the sandbox flag decides how much it may touch. workspace-write
|
|
3
|
+
// is the minimum needed to edit files + commit inside the task worktree.
|
|
4
|
+
function cmdFor(task, cfg) {
|
|
5
|
+
const args = ['exec'];
|
|
6
|
+
if (cfg.bypass_permissions) args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
7
|
+
else args.push('-s', 'workspace-write');
|
|
8
|
+
for (const a of cfg.codex_args || []) args.push(a);
|
|
9
|
+
args.push(task.body);
|
|
10
|
+
return { cmd: ['codex', ...args], stdin: null };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// exec prints a human-readable session log; the last non-empty line is the
|
|
14
|
+
// closest thing to a result. Exit code is the authoritative verdict.
|
|
15
|
+
function parse({ exitCode, stdout }) {
|
|
16
|
+
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
17
|
+
const summary = (lines.length ? lines[lines.length - 1] : '').trim().slice(0, 200);
|
|
18
|
+
return { ok: exitCode === 0, summary: summary || '(no output)' };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { name: 'codex', cmdFor, parse };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// adapter registry. An adapter is { name, cmdFor(task, cfg), parse(result) }.
|
|
2
|
+
// cmdFor returns { cmd: [argv0, ...args], stdin?, cwdHint? } — supervisor
|
|
3
|
+
// always runs it with cwd set to the task's worktree.
|
|
4
|
+
const stub = require('./stub');
|
|
5
|
+
const claude = require('./claude');
|
|
6
|
+
const codex = require('./codex');
|
|
7
|
+
|
|
8
|
+
const REGISTRY = { stub, claude, codex };
|
|
9
|
+
|
|
10
|
+
function getAdapter(name) {
|
|
11
|
+
const a = REGISTRY[name];
|
|
12
|
+
if (!a) {
|
|
13
|
+
throw new Error(`Unknown adapter "${name}" — available: ${Object.keys(REGISTRY).join(', ')}`);
|
|
14
|
+
}
|
|
15
|
+
return a;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { REGISTRY, getAdapter };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stub-agent.js — stands in for a real agent CLI in tests and dry runs.
|
|
3
|
+
// Behavior is steered by directives embedded in the task file (argv[2]):
|
|
4
|
+
// <!-- stub:fail --> print a plain error and exit 1
|
|
5
|
+
// <!-- stub:ratelimit --> print a usage-limit error and exit 1 (retryable)
|
|
6
|
+
// On success it simulates real work: writes a file into the worktree and
|
|
7
|
+
// commits it, so the report has receipts to show.
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const GIT_STUB_ENV = {
|
|
13
|
+
GIT_AUTHOR_NAME: 'Awayteam Stub',
|
|
14
|
+
GIT_AUTHOR_EMAIL: 'stub@awayteam.dev',
|
|
15
|
+
GIT_COMMITTER_NAME: 'Awayteam Stub',
|
|
16
|
+
GIT_COMMITTER_EMAIL: 'stub@awayteam.dev',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const taskPath = process.argv[2];
|
|
20
|
+
const body = fs.readFileSync(taskPath, 'utf8');
|
|
21
|
+
const slug = path.basename(taskPath, '.md');
|
|
22
|
+
const cwd = process.cwd(); // a awayteam worktree
|
|
23
|
+
|
|
24
|
+
if (/<!--\s*stub:ratelimit\s*-->/.test(body)) {
|
|
25
|
+
console.error('error: usage limit reached, try again later');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (/<!--\s*stub:fail\s*-->/.test(body)) {
|
|
30
|
+
console.error('stub: deliberate failure');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const file = `stub-${slug}.txt`;
|
|
35
|
+
fs.writeFileSync(path.join(cwd, file), `done by awayteam stub for ${slug}\n`);
|
|
36
|
+
execFileSync('git', ['add', '-A'], { cwd });
|
|
37
|
+
execFileSync('git', ['commit', '-m', `stub: ${slug}`], { cwd, env: { ...process.env, ...GIT_STUB_ENV } });
|
|
38
|
+
console.log(`AWAYTEAM_RESULT ok wrote ${file}`);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// stub adapter — a deterministic fake agent CLI. It exists so the whole
|
|
2
|
+
// fleet loop can be tested and demoed without touching a real provider.
|
|
3
|
+
// The "agent" is stub-agent.js; failure modes are driven by directives
|
|
4
|
+
// embedded in the task body.
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const STUB_AGENT_PATH = path.join(__dirname, 'stub-agent.js');
|
|
8
|
+
|
|
9
|
+
function cmdFor(task, cfg) {
|
|
10
|
+
return {
|
|
11
|
+
cmd: [process.execPath, STUB_AGENT_PATH, task.path],
|
|
12
|
+
stdin: null,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// stub-agent prints a final `AWAYTEAM_RESULT <status>` line.
|
|
17
|
+
function parse({ exitCode, stdout }) {
|
|
18
|
+
const m = stdout.match(/AWAYTEAM_RESULT\s+(\w+)/);
|
|
19
|
+
const outcome = m ? m[1] : exitCode === 0 ? 'ok' : 'failed';
|
|
20
|
+
return { ok: outcome === 'ok', summary: `stub: ${outcome}` };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { name: 'stub', cmdFor, parse };
|
package/lib/board.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// board.js — the task board state machine.
|
|
2
|
+
//
|
|
3
|
+
// Board layout (one board per job pack):
|
|
4
|
+
// boards/<pack>/todo/*.md
|
|
5
|
+
// boards/<pack>/in-progress/*.md
|
|
6
|
+
// boards/<pack>/done/*.md
|
|
7
|
+
// boards/<pack>/failed/*.md
|
|
8
|
+
//
|
|
9
|
+
// Tasks are plain markdown files with `---key: value---` front matter; the
|
|
10
|
+
// SUBDIRECTORY IS THE STATUS. Claiming is an atomic rename from todo/ into
|
|
11
|
+
// in-progress/, so parallel workers race safely: the loser of a claim gets
|
|
12
|
+
// ENOENT on its source file and simply tries the next one. No locks, no DB.
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const STATUSES = ['todo', 'in-progress', 'done', 'failed'];
|
|
17
|
+
|
|
18
|
+
// --- task file format ---------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
function parseTask(text) {
|
|
21
|
+
const meta = {};
|
|
22
|
+
const lines = text.split('\n');
|
|
23
|
+
let i = 0;
|
|
24
|
+
if ((lines[0] || '').trim() !== '---') return { meta, body: text.trim() };
|
|
25
|
+
for (i = 1; i < lines.length; i++) {
|
|
26
|
+
if (lines[i].trim() === '---') { i++; break; }
|
|
27
|
+
const m = lines[i].match(/^([\w-]+):\s*(.*)$/);
|
|
28
|
+
if (m) meta[m[1]] = m[2];
|
|
29
|
+
}
|
|
30
|
+
return { meta, body: lines.slice(i).join('\n').trim() };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderTask({ meta, body }) {
|
|
34
|
+
const fm = Object.entries(meta).map(([k, v]) => `${k}: ${v}`);
|
|
35
|
+
return ['---', ...fm, '---', '', body, ''].join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadTask(filePath) {
|
|
39
|
+
return parseTask(fs.readFileSync(filePath, 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// tmp + atomic rename so a reader never sees a half-written front matter.
|
|
43
|
+
function writeTask(filePath, task) {
|
|
44
|
+
const tmp = filePath + '.tmp';
|
|
45
|
+
fs.writeFileSync(tmp, renderTask(task));
|
|
46
|
+
fs.renameSync(tmp, filePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- board operations ---------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function statusDir(boardDir, status) {
|
|
52
|
+
const d = path.join(boardDir, status);
|
|
53
|
+
fs.mkdirSync(d, { recursive: true });
|
|
54
|
+
return d;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function listTasks(boardDir, status) {
|
|
58
|
+
try {
|
|
59
|
+
return fs.readdirSync(path.join(boardDir, status)).filter((f) => f.endsWith('.md')).sort();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err.code === 'ENOENT') return [];
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function boardCounts(boardDir) {
|
|
67
|
+
const counts = {};
|
|
68
|
+
for (const s of STATUSES) counts[s] = listTasks(boardDir, s).length;
|
|
69
|
+
return counts;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function slugOf(fileName) {
|
|
73
|
+
return fileName.replace(/\.md$/, '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Returns the claimed task ({ name, path, slug, meta, body }) or null.
|
|
77
|
+
function claimNext(boardDir, workerId, stamp) {
|
|
78
|
+
const todo = statusDir(boardDir, 'todo');
|
|
79
|
+
const names = listTasks(boardDir, 'todo');
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
const dst = path.join(statusDir(boardDir, 'in-progress'), name);
|
|
82
|
+
try {
|
|
83
|
+
fs.renameSync(path.join(todo, name), dst);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code === 'ENOENT') continue; // lost the race — next task
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
const task = loadTask(dst);
|
|
89
|
+
task.meta.worker = workerId;
|
|
90
|
+
task.meta.claimed_at = stamp || new Date().toISOString();
|
|
91
|
+
if (!task.meta.attempts) task.meta.attempts = '0';
|
|
92
|
+
writeTask(dst, task);
|
|
93
|
+
return {
|
|
94
|
+
pack: path.basename(boardDir),
|
|
95
|
+
name,
|
|
96
|
+
path: dst,
|
|
97
|
+
slug: slugOf(name),
|
|
98
|
+
meta: task.meta,
|
|
99
|
+
body: task.body,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Moves a task to another status dir, patching front matter in place first.
|
|
106
|
+
function setTaskStatus(task, to, patch = {}) {
|
|
107
|
+
const boardDir = path.dirname(path.dirname(task.path));
|
|
108
|
+
const from = path.basename(path.dirname(task.path));
|
|
109
|
+
Object.assign(task.meta, { status: to, updated_at: new Date().toISOString() }, patch);
|
|
110
|
+
writeTask(task.path, task);
|
|
111
|
+
if (from !== to) {
|
|
112
|
+
const dst = path.join(statusDir(boardDir, to), path.basename(task.path));
|
|
113
|
+
fs.renameSync(task.path, dst);
|
|
114
|
+
task.path = dst;
|
|
115
|
+
}
|
|
116
|
+
return task;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function seedBoard(boardDir, tasks) {
|
|
120
|
+
for (const s of STATUSES) statusDir(boardDir, s);
|
|
121
|
+
for (const t of tasks) {
|
|
122
|
+
const file = path.join(statusDir(boardDir, 'todo'), t.name);
|
|
123
|
+
if (!fs.existsSync(file)) writeTask(file, { meta: { status: 'todo', attempts: 0 }, body: t.body });
|
|
124
|
+
}
|
|
125
|
+
return boardDir;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
STATUSES,
|
|
130
|
+
parseTask,
|
|
131
|
+
renderTask,
|
|
132
|
+
loadTask,
|
|
133
|
+
writeTask,
|
|
134
|
+
listTasks,
|
|
135
|
+
boardCounts,
|
|
136
|
+
claimNext,
|
|
137
|
+
setTaskStatus,
|
|
138
|
+
seedBoard,
|
|
139
|
+
slugOf,
|
|
140
|
+
};
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// cli.js — command dispatch and receipt printing. bin/awayteam.js is a
|
|
2
|
+
// two-liner; everything user-visible lives here.
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const config = require('./config');
|
|
8
|
+
const board = require('./board');
|
|
9
|
+
const wt = require('./worktree');
|
|
10
|
+
const { runFleet } = require('./supervisor');
|
|
11
|
+
const { buildReport } = require('./report');
|
|
12
|
+
const { showStatus } = require('./status');
|
|
13
|
+
const { stopFleet } = require('./stopctl');
|
|
14
|
+
|
|
15
|
+
const HELP = `awayteam - run overnight agent fleets, wake up to a report.
|
|
16
|
+
|
|
17
|
+
awayteam init detect agent CLIs, write awayteam.config.json + demo board
|
|
18
|
+
awayteam up run the fleet (until the board is empty or the window ends)
|
|
19
|
+
--jobs N parallel workers (config default: 2)
|
|
20
|
+
--board DIR task board directory (config default: boards/demo)
|
|
21
|
+
--window 8h stop starting new work after this long
|
|
22
|
+
--adapter NAME override adapter for this run
|
|
23
|
+
awayteam status live view of the fleet (from another terminal)
|
|
24
|
+
awayteam stop [--now] finish running tasks then stop / kill immediately
|
|
25
|
+
awayteam report render the latest morning report
|
|
26
|
+
|
|
27
|
+
Adapters: claude, codex, stub (simulated - for tests and dry runs).
|
|
28
|
+
Isolation: every task runs in its own git worktree + branch. Nothing is merged for you.
|
|
29
|
+
Safety: agent permission bypass is OFF by default. See README before enabling overnight.`;
|
|
30
|
+
|
|
31
|
+
const stamp = () => new Date().toISOString().slice(11, 19);
|
|
32
|
+
|
|
33
|
+
function parseWindow(s) {
|
|
34
|
+
const m = /^(\d+)(h|m|s)$/.exec(s);
|
|
35
|
+
if (!m) throw new Error(`bad --window "${s}" — use e.g. 8h, 90m, 45s`);
|
|
36
|
+
const mult = { h: 1, m: 1 / 60, s: 1 / 3600 };
|
|
37
|
+
return parseInt(m[1], 10) * mult[m[2]];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseFlags(rest) {
|
|
41
|
+
const out = {};
|
|
42
|
+
const need = (i) => {
|
|
43
|
+
const v = rest[i + 1];
|
|
44
|
+
if (v === undefined) throw new Error(`flag ${rest[i]} needs a value`);
|
|
45
|
+
return v;
|
|
46
|
+
};
|
|
47
|
+
for (let i = 0; i < rest.length; i++) {
|
|
48
|
+
const k = rest[i];
|
|
49
|
+
const need = () => {
|
|
50
|
+
const v = rest[i + 1];
|
|
51
|
+
if (v === undefined) throw new Error(`flag ${k} needs a value`);
|
|
52
|
+
i += 1; // consume the value
|
|
53
|
+
return v;
|
|
54
|
+
};
|
|
55
|
+
if (k === '--jobs') out.jobs = parseInt(need(), 10);
|
|
56
|
+
else if (k === '--window') out.windowHours = parseWindow(need());
|
|
57
|
+
else if (k === '--board') out.board = need();
|
|
58
|
+
else if (k === '--adapter') out.adapter = need();
|
|
59
|
+
else throw new Error(`unknown flag: ${k}`);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEMO_TASK = {
|
|
65
|
+
name: '001-demo-hello.md',
|
|
66
|
+
body: [
|
|
67
|
+
'Create a file named `demo.txt` containing the line "hello from awayteam",',
|
|
68
|
+
'then commit the file.',
|
|
69
|
+
'',
|
|
70
|
+
'This task exists so you can watch a full run end-to-end (claim -> worktree',
|
|
71
|
+
'-> commit -> report) before pointing awayteam at your real backlog.',
|
|
72
|
+
].join('\n'),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function cmdInit() {
|
|
76
|
+
const repoRoot = process.cwd();
|
|
77
|
+
wt.assertRepo(repoRoot);
|
|
78
|
+
|
|
79
|
+
const probe = (bin, args) => {
|
|
80
|
+
try {
|
|
81
|
+
execFileSync(bin, args, { timeout: 5000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
82
|
+
return true;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let adapter = null;
|
|
89
|
+
if (probe('claude', ['--version'])) adapter = 'claude';
|
|
90
|
+
else if (probe('codex', ['--version'])) adapter = 'codex';
|
|
91
|
+
const cfgFile = config.writeConfig(repoRoot, adapter ? { adapter } : {});
|
|
92
|
+
const boardDir = board.seedBoard(path.join(repoRoot, config.DEFAULTS.board), [DEMO_TASK]);
|
|
93
|
+
|
|
94
|
+
console.log(`config: ${cfgFile} (adapter: ${adapter || 'stub'})`);
|
|
95
|
+
console.log(`board: ${path.relative(repoRoot, boardDir)} (1 demo task)`);
|
|
96
|
+
console.log(`repo: ${repoRoot}`);
|
|
97
|
+
if (!adapter) {
|
|
98
|
+
console.log('');
|
|
99
|
+
console.log('No supported agent CLI found on PATH (looked for: claude).');
|
|
100
|
+
console.log('Falling back to the stub adapter — the full loop works, the agents are simulated.');
|
|
101
|
+
}
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log('next:');
|
|
104
|
+
console.log(' awayteam up run the fleet now');
|
|
105
|
+
console.log(' awayteam status watch from another terminal');
|
|
106
|
+
console.log(' awayteam report read the morning report');
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function cmdUp(flags) {
|
|
111
|
+
const repoRoot = process.cwd();
|
|
112
|
+
const cfg = config.loadConfig(repoRoot);
|
|
113
|
+
|
|
114
|
+
const run = await runFleet(repoRoot, cfg, {
|
|
115
|
+
...flags,
|
|
116
|
+
onEvent(kind, e) {
|
|
117
|
+
const t = `[${stamp()}] ${e.worker} `;
|
|
118
|
+
if (kind === 'claimed') console.log(`${t}claimed ${e.task}`);
|
|
119
|
+
else if (kind === 'done') console.log(`${t}done ${e.task} (attempt ${e.attempts}) -> ${e.branch}`);
|
|
120
|
+
else if (kind === 'backoff') console.log(`${t}retry ${e.task} attempt ${e.attempts + 1} next, backoff ${Math.round(e.delay_ms / 60e3) || e.delay_ms + 's'}`);
|
|
121
|
+
else if (kind === 'failed') console.log(`${t}FAIL ${e.task} (${e.reason}, exit ${e.exit_code})`);
|
|
122
|
+
else if (kind === 'abandoned') console.log(`${t}stopped ${e.task} — left in-progress`);
|
|
123
|
+
else console.log(`${t}${kind} ${e.task || ''}`);
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const r = buildReport(repoRoot);
|
|
128
|
+
const { done, failed, abandoned, queued } = r.summary;
|
|
129
|
+
console.log('');
|
|
130
|
+
console.log(`run complete - ${started(run)} - done ${done}, failed ${failed}, abandoned ${abandoned}, queued ${queued}`);
|
|
131
|
+
console.log(`report: ${path.relative(repoRoot, r.file)}`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function started(run) {
|
|
136
|
+
return `board ${run.board} (${run.jobs} workers, ${run.adapter})`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function cmdReport() {
|
|
140
|
+
const repoRoot = process.cwd();
|
|
141
|
+
const r = buildReport(repoRoot);
|
|
142
|
+
console.log(`report: ${path.relative(repoRoot, r.file)}`);
|
|
143
|
+
console.log('');
|
|
144
|
+
console.log(r.markdown);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function main(argv) {
|
|
149
|
+
const [cmd, ...rest] = argv;
|
|
150
|
+
switch (cmd === undefined ? 'help' : cmd) {
|
|
151
|
+
case '-h':
|
|
152
|
+
case '--help':
|
|
153
|
+
case 'help':
|
|
154
|
+
console.log(HELP);
|
|
155
|
+
return 0;
|
|
156
|
+
case 'init':
|
|
157
|
+
return cmdInit();
|
|
158
|
+
case 'up':
|
|
159
|
+
return cmdUp(parseFlags(rest));
|
|
160
|
+
case 'status':
|
|
161
|
+
showStatus(process.cwd());
|
|
162
|
+
return 0;
|
|
163
|
+
case 'stop':
|
|
164
|
+
stopFleet(process.cwd(), { now: rest.includes('--now') });
|
|
165
|
+
return 0;
|
|
166
|
+
case 'report':
|
|
167
|
+
return cmdReport();
|
|
168
|
+
default:
|
|
169
|
+
throw new Error(`unknown command: ${cmd}\n\n${HELP}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { main, parseFlags, parseWindow };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// config.js — awayteam.config.json loading. One flat object, sane defaults,
|
|
2
|
+
// explicit on disk so users can diff/inspect exactly what a run used.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const FILE = 'awayteam.config.json';
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
adapter: 'stub', // stub | claude | codex
|
|
10
|
+
jobs: 2,
|
|
11
|
+
board: 'boards/demo',
|
|
12
|
+
window_hours: 8, // stop starting NEW work after this many hours
|
|
13
|
+
max_attempts: 3, // executions per task before it is marked failed
|
|
14
|
+
backoff_base_seconds: 120, // rate-limit backoff base: 2m, 4m, 8m ...
|
|
15
|
+
task_timeout_minutes: 120, // kill an agent process stuck this long (0 = off)
|
|
16
|
+
bypass_permissions: false, // god mode for the agent CLI; off by default, on purpose
|
|
17
|
+
notify_command: '', // shell command; run events arrive as JSON on stdin
|
|
18
|
+
claude_args: [], // extra args passed to `claude`
|
|
19
|
+
codex_args: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function configPath(repoRoot) {
|
|
23
|
+
return path.join(repoRoot, FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadConfig(repoRoot) {
|
|
27
|
+
const p = configPath(repoRoot);
|
|
28
|
+
if (!fs.existsSync(p)) {
|
|
29
|
+
throw new Error(`No ${FILE} in ${repoRoot} — run \`awayteam init\` first.`);
|
|
30
|
+
}
|
|
31
|
+
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
32
|
+
return { ...DEFAULTS, ...raw };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeConfig(repoRoot, patch) {
|
|
36
|
+
const p = configPath(repoRoot);
|
|
37
|
+
const base = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
|
|
38
|
+
const merged = { ...DEFAULTS, ...base, ...patch };
|
|
39
|
+
fs.writeFileSync(p, JSON.stringify(merged, null, 2) + '\n');
|
|
40
|
+
return p;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { DEFAULTS, FILE, configPath, loadConfig, writeConfig };
|
package/lib/notify.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// notify.js — fire-and-forget notification hook. Config points at any shell
|
|
2
|
+
// command; run events arrive as JSON on stdin. Slack webhook, Telegram curl,
|
|
3
|
+
// sendmail — whatever the operator wants, awayteam collects nothing.
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
|
|
6
|
+
function callNotify(cfg, payload) {
|
|
7
|
+
const cmd = ((cfg && cfg.notify_command) || '').trim();
|
|
8
|
+
if (!cmd) return false;
|
|
9
|
+
try {
|
|
10
|
+
const child = spawn('sh', ['-c', cmd], { stdio: ['pipe', 'ignore', 'ignore'], detached: true });
|
|
11
|
+
child.stdin.end(JSON.stringify(payload) + '\n');
|
|
12
|
+
child.unref();
|
|
13
|
+
return true;
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.error(`[notify] hook failed: ${err.message}`);
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { callNotify };
|
package/lib/proc.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// proc.js — async child process helper. The supervisor runs N workers as
|
|
2
|
+
// cooperative async loops in one process, so agent CLIs must be spawned
|
|
3
|
+
// (not spawnSync) or the whole fleet would serialize.
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
|
|
6
|
+
// resolves { exitCode, stdout, stderr, pid, timedOut }
|
|
7
|
+
function runCapture(cmd, { cwd, input, env, timeoutMs } = {}) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
let stdout = '';
|
|
10
|
+
let stderr = '';
|
|
11
|
+
let settled = false;
|
|
12
|
+
let timedOut = false;
|
|
13
|
+
|
|
14
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
15
|
+
cwd,
|
|
16
|
+
env: env || process.env,
|
|
17
|
+
detached: true, // own process group, so a timed-out agent dies with its children
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Cap captured output — an agent that chatters should not OOM us.
|
|
21
|
+
const append = (want) => (d) => {
|
|
22
|
+
if (want === 'out' && stdout.length < 2e6) stdout += d;
|
|
23
|
+
if (want === 'err' && stderr.length < 2e6) {
|
|
24
|
+
stderr += d;
|
|
25
|
+
if (stderr.length >= 2e6) stderr += '\n... (truncated)';
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
child.stdout.on('data', append('out'));
|
|
30
|
+
child.stderr.on('data', append('err'));
|
|
31
|
+
|
|
32
|
+
const timer = timeoutMs
|
|
33
|
+
? setTimeout(() => {
|
|
34
|
+
timedOut = true;
|
|
35
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch (err) { child.kill('SIGTERM'); }
|
|
36
|
+
}, timeoutMs)
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
const finish = (exitCode) => {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
if (timer) clearTimeout(timer);
|
|
43
|
+
resolve({ exitCode, stdout, stderr, pid: child.pid, timedOut });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
child.on('error', (err) => {
|
|
47
|
+
stderr += `\nspawn error: ${err.message}`;
|
|
48
|
+
finish(127);
|
|
49
|
+
});
|
|
50
|
+
child.on('close', (code) => finish(code ?? (timedOut ? 124 : 1)));
|
|
51
|
+
|
|
52
|
+
if (input != null) child.stdin.write(input);
|
|
53
|
+
child.stdin.end();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { runCapture };
|
package/lib/ratelimit.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ratelimit.js — classify why an agent CLI exited nonzero. Rate limits are
|
|
2
|
+
// retryable with backoff (that is the whole overnight game); everything else
|
|
3
|
+
// fails immediately so the morning report can show the stderr.
|
|
4
|
+
const RATE_LIMIT_PATTERNS = [
|
|
5
|
+
/rate[- ]?limit/i,
|
|
6
|
+
/usage[- ]?limit/i,
|
|
7
|
+
/too many requests/i,
|
|
8
|
+
/\b429\b/,
|
|
9
|
+
/overloaded/i,
|
|
10
|
+
/quota/i,
|
|
11
|
+
/exhausted/i,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function classifyExit({ exitCode, stdout, stderr }) {
|
|
15
|
+
if (exitCode === 0 && !/\b(is_error":\s*true)\b/.test(stdout)) return 'success';
|
|
16
|
+
const text = `${stdout}\n${stderr}`;
|
|
17
|
+
if (RATE_LIMIT_PATTERNS.some((re) => re.test(text))) return 'rate-limit';
|
|
18
|
+
return 'error';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { classifyExit, RATE_LIMIT_PATTERNS };
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// report.js — the morning report: what the fleet did while you slept.
|
|
2
|
+
// Board outcomes first, then per-branch receipts (commits, diffstat, review
|
|
3
|
+
// command) pulled from git history so the report survives worktree cleanup.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const boardMod = require('./board');
|
|
7
|
+
const wt = require('./worktree');
|
|
8
|
+
|
|
9
|
+
function reportsDir(repoRoot) {
|
|
10
|
+
return path.join(repoRoot, '.awayteam', 'reports');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function latestRunFile(repoRoot) {
|
|
14
|
+
const dir = path.join(repoRoot, '.awayteam', 'runs');
|
|
15
|
+
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort() : [];
|
|
16
|
+
if (!files.length) throw new Error('No awayteam runs recorded yet — run `awayteam up` first.');
|
|
17
|
+
return path.join(dir, files[files.length - 1]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildReport(repoRoot, { runFile } = {}) {
|
|
21
|
+
const file = runFile || latestRunFile(repoRoot);
|
|
22
|
+
const run = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
23
|
+
const boardDir = path.resolve(repoRoot, run.board);
|
|
24
|
+
const done = run.results.filter((r) => r.status === 'done');
|
|
25
|
+
const failed = run.results.filter((r) => r.status.startsWith('failed'));
|
|
26
|
+
const abandoned = run.results.filter((r) => r.status === 'abandoned');
|
|
27
|
+
const queued = boardMod.listTasks(boardDir, 'todo');
|
|
28
|
+
const held = boardMod.listTasks(boardDir, 'in-progress');
|
|
29
|
+
|
|
30
|
+
const L = [];
|
|
31
|
+
L.push(`# Awayteam report — ${run.ended_at.slice(0, 10)}`);
|
|
32
|
+
L.push('');
|
|
33
|
+
L.push(`- window: \`${run.started_at}\` -> \`${run.ended_at}\``);
|
|
34
|
+
L.push(`- workers: ${run.jobs} - adapter: \`${run.adapter}\` - board: \`${run.board}\``);
|
|
35
|
+
L.push('');
|
|
36
|
+
L.push('## Outcomes');
|
|
37
|
+
L.push('');
|
|
38
|
+
L.push('| task | status | branch | attempts | exit |');
|
|
39
|
+
L.push('|---|---|---|---|---|');
|
|
40
|
+
for (const r of run.results) {
|
|
41
|
+
L.push(`| ${r.task} | ${r.status} | \`${r.branch}\` | ${r.attempts} | ${r.exit_code ?? '-'} |`);
|
|
42
|
+
}
|
|
43
|
+
for (const t of held) L.push(`| ${t} | in-progress | - | - | - |`);
|
|
44
|
+
if (!run.results.length && !queued.length && !held.length) L.push('| (no work found) | - | - | - | - |');
|
|
45
|
+
L.push('');
|
|
46
|
+
|
|
47
|
+
if (done.length) {
|
|
48
|
+
L.push('## Ready for review');
|
|
49
|
+
for (const r of done) {
|
|
50
|
+
L.push('');
|
|
51
|
+
L.push(`### ${r.task.replace(/\.md$/, '')}`);
|
|
52
|
+
L.push('');
|
|
53
|
+
L.push(`branch \`${r.branch}\` - ${r.attempts} attempt(s)${r.summary ? ` - ${r.summary}` : ''}`);
|
|
54
|
+
const log = wt.commitLog(repoRoot, run.start_head, r.branch).trim();
|
|
55
|
+
if (log) {
|
|
56
|
+
L.push('');
|
|
57
|
+
L.push('```');
|
|
58
|
+
L.push(log);
|
|
59
|
+
L.push('```');
|
|
60
|
+
}
|
|
61
|
+
const stat = wt.diffStat(repoRoot, run.start_head, r.branch).trim();
|
|
62
|
+
if (stat) {
|
|
63
|
+
L.push('');
|
|
64
|
+
L.push('```');
|
|
65
|
+
L.push(stat);
|
|
66
|
+
L.push('```');
|
|
67
|
+
}
|
|
68
|
+
L.push('');
|
|
69
|
+
L.push(`review: \`git log -p ${run.start_head.slice(0, 9)}..${r.branch}\``);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (failed.length) {
|
|
74
|
+
L.push('');
|
|
75
|
+
L.push('## Failed');
|
|
76
|
+
for (const r of failed) {
|
|
77
|
+
L.push('');
|
|
78
|
+
L.push(`### ${r.task.replace(/\.md$/, '')}`);
|
|
79
|
+
L.push('');
|
|
80
|
+
L.push(`branch \`${r.branch}\` - ${r.attempts} attempt(s) - exit ${r.exit_code ?? '?'}`);
|
|
81
|
+
if (r.error) {
|
|
82
|
+
L.push('');
|
|
83
|
+
L.push('```');
|
|
84
|
+
L.push(r.error);
|
|
85
|
+
L.push('```');
|
|
86
|
+
}
|
|
87
|
+
const stat = wt.diffStat(repoRoot, run.start_head, r.branch).trim();
|
|
88
|
+
if (stat) L.push('partial work exists on the branch - inspect before retrying.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (abandoned.length) {
|
|
93
|
+
L.push('');
|
|
94
|
+
L.push('## Stopped mid-task (left in-progress for the next run)');
|
|
95
|
+
for (const r of abandoned) L.push(`- ${r.task}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (queued.length) {
|
|
99
|
+
L.push('');
|
|
100
|
+
L.push('## Still queued');
|
|
101
|
+
L.push('');
|
|
102
|
+
for (const t of queued) L.push(`- ${t}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const md = L.join('\n') + '\n';
|
|
106
|
+
const out = path.join(reportsDir(repoRoot), `${run.ended_at.slice(0, 10)}.md`);
|
|
107
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
108
|
+
fs.writeFileSync(out + '.tmp', md);
|
|
109
|
+
fs.renameSync(out + '.tmp', out);
|
|
110
|
+
return {
|
|
111
|
+
markdown: md,
|
|
112
|
+
file: out,
|
|
113
|
+
summary: {
|
|
114
|
+
done: done.length,
|
|
115
|
+
failed: failed.length,
|
|
116
|
+
abandoned: abandoned.length,
|
|
117
|
+
queued: queued.length,
|
|
118
|
+
held: held.length,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { buildReport, latestRunFile };
|
package/lib/status.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// status.js — live view of a run, read from the .awayteam state files.
|
|
2
|
+
// Works from any other terminal while `awayteam up` is running.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const boardMod = require('./board');
|
|
6
|
+
|
|
7
|
+
const fleetPath = (repoRoot, ...p) => path.join(repoRoot, '.awayteam', ...p);
|
|
8
|
+
|
|
9
|
+
function readJson(file) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
12
|
+
} catch (err) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function showStatus(repoRoot) {
|
|
18
|
+
const fleetFile = fleetPath(repoRoot, 'fleet.json');
|
|
19
|
+
const fleet = readJson(fleetFile);
|
|
20
|
+
if (!fleet) {
|
|
21
|
+
console.log('No active fleet (no .awayteam/fleet.json) — run `awayteam up` or `awayteam report`.');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stopped = fs.existsSync(fleetPath(repoRoot, 'stop.flag'));
|
|
26
|
+
const hours = ((Date.now() - Date.parse(fleet.started_at)) / 3600e3).toFixed(2);
|
|
27
|
+
console.log(`fleet: running ${hours}h (pid ${fleet.pid}) - adapter: ${fleet.adapter} - board: ${fleet.board}`);
|
|
28
|
+
console.log(stopped ? 'stop flag set — no new tasks will start' : '');
|
|
29
|
+
|
|
30
|
+
const wdir = fleetPath(repoRoot, 'workers');
|
|
31
|
+
const files = fs.existsSync(wdir) ? fs.readdirSync(wdir).filter((f) => f.endsWith('.json')).sort() : [];
|
|
32
|
+
if (!files.length) console.log('workers: (none recorded)');
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
const w = readJson(path.join(wdir, f));
|
|
35
|
+
if (!w) continue;
|
|
36
|
+
const bits = [w.phase];
|
|
37
|
+
if (w.task) bits.push(`${w.task} (attempt ${w.attempts ?? '?'})`);
|
|
38
|
+
if (w.phase === 'backoff') bits.push(`resume in ${Math.round(w.resume_in_ms / 600e3) / 10}m`);
|
|
39
|
+
bits.push(w.ts);
|
|
40
|
+
console.log(` ${w.id} ${bits.join(' ')}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const counts = boardMod.boardCounts(path.resolve(repoRoot, fleet.board));
|
|
45
|
+
const parts = boardMod.STATUSES.map((s) => `${s}: ${counts[s]}`).join(' - ');
|
|
46
|
+
console.log(`board: ${parts}`);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.log(`board: unreadable (${err.message})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { showStatus };
|
package/lib/stopctl.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// stopctl.js — graceful stop: drop a flag that workers honor at the task
|
|
2
|
+
// boundary. `--now` additionally kills the fleet process and the agent
|
|
3
|
+
// processes it spawned (recorded as agent_pid in the worker state files).
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const fleetPath = (repoRoot, ...p) => path.join(repoRoot, '.awayteam', ...p);
|
|
8
|
+
|
|
9
|
+
function stopFleet(repoRoot, { now = false } = {}) {
|
|
10
|
+
const file = fleetPath(repoRoot, 'fleet.json');
|
|
11
|
+
if (!fs.existsSync(file)) {
|
|
12
|
+
throw new Error('No active fleet (no .awayteam/fleet.json).');
|
|
13
|
+
}
|
|
14
|
+
const fleet = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
15
|
+
|
|
16
|
+
fs.writeFileSync(fleetPath(repoRoot, 'stop.flag'), new Date().toISOString());
|
|
17
|
+
if (!now) {
|
|
18
|
+
console.log('stop flag set - workers finish their current task, then exit.');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Kill what the workers are running, then the fleet itself.
|
|
23
|
+
const wdir = fleetPath(repoRoot, 'workers');
|
|
24
|
+
const files = fs.existsSync(wdir) ? fs.readdirSync(wdir).filter((f) => f.endsWith('.json')) : [];
|
|
25
|
+
for (const f of files) {
|
|
26
|
+
try {
|
|
27
|
+
const w = JSON.parse(fs.readFileSync(path.join(wdir, f), 'utf8'));
|
|
28
|
+
if (!w.agent_pid) continue;
|
|
29
|
+
try {
|
|
30
|
+
process.kill(-w.agent_pid, 'SIGTERM'); // detached spawn => own process group
|
|
31
|
+
console.log(`killed agent ${w.agent_pid} (${w.task || f})`);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
try { process.kill(w.agent_pid, 'SIGTERM'); } catch (e2) { /* already gone */ }
|
|
34
|
+
}
|
|
35
|
+
} catch (err) { /* unreadable state file — skip */ }
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
process.kill(fleet.pid, 'SIGTERM');
|
|
39
|
+
console.log('SIGTERM sent to the fleet process.');
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.log('fleet process already gone.');
|
|
42
|
+
}
|
|
43
|
+
console.log('Tasks left in-progress stay in-progress — the next run picks them up.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { stopFleet };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// supervisor.js — runs the fleet: N async workers over one shared board.
|
|
2
|
+
// Per task: claim -> worktree -> run adapter -> classify -> done / failed /
|
|
3
|
+
// rate-limit backoff (same task, same worktree, up to max_attempts).
|
|
4
|
+
// Receipts go three places: the task file front matter, the worker state
|
|
5
|
+
// file (.awayteam/workers/), and the run record (.awayteam/runs/).
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const board = require('./board');
|
|
10
|
+
const adapters = require('./adapters');
|
|
11
|
+
const wt = require('./worktree');
|
|
12
|
+
const { runCapture } = require('./proc');
|
|
13
|
+
const { classifyExit } = require('./ratelimit');
|
|
14
|
+
const { callNotify } = require('./notify');
|
|
15
|
+
|
|
16
|
+
const fleetPath = (repoRoot, ...parts) => path.join(repoRoot, '.awayteam', ...parts);
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
|
|
19
|
+
async function runFleet(repoRoot, cfg, overrides = {}) {
|
|
20
|
+
wt.assertRepo(repoRoot);
|
|
21
|
+
|
|
22
|
+
const jobs = Math.max(1, overrides.jobs ?? cfg.jobs);
|
|
23
|
+
const boardDir = path.resolve(repoRoot, overrides.board ?? cfg.board);
|
|
24
|
+
const windowHours = overrides.windowHours ?? cfg.window_hours;
|
|
25
|
+
const maxAttempts = overrides.maxAttempts ?? cfg.max_attempts;
|
|
26
|
+
const backoffBaseMs = (overrides.backoffBaseSeconds ?? cfg.backoff_base_seconds) * 1000;
|
|
27
|
+
const backoffCapMs = overrides.backoffCapMs ?? 15 * 60e3;
|
|
28
|
+
const idleWaitMs = overrides.idleWaitMs ?? 750;
|
|
29
|
+
const taskTimeoutMs = cfg.task_timeout_minutes > 0 ? cfg.task_timeout_minutes * 60e3 : 0;
|
|
30
|
+
const adapter = adapters.getAdapter(overrides.adapter ?? cfg.adapter);
|
|
31
|
+
const onEvent = overrides.onEvent || (() => {});
|
|
32
|
+
|
|
33
|
+
fs.mkdirSync(fleetPath(repoRoot, 'runs'), { recursive: true });
|
|
34
|
+
fs.mkdirSync(fleetPath(repoRoot, 'workers'), { recursive: true });
|
|
35
|
+
try { fs.unlinkSync(fleetPath(repoRoot, 'stop.flag')); } catch (err) { /* fresh run */ }
|
|
36
|
+
|
|
37
|
+
const startedAt = new Date().toISOString();
|
|
38
|
+
const deadline = Date.now() + windowHours * 3600e3;
|
|
39
|
+
const startHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim();
|
|
40
|
+
|
|
41
|
+
// fleet state for `status` / `stop`
|
|
42
|
+
fs.writeFileSync(
|
|
43
|
+
fleetPath(repoRoot, 'fleet.json'),
|
|
44
|
+
JSON.stringify(
|
|
45
|
+
{
|
|
46
|
+
pid: process.pid,
|
|
47
|
+
started_at: startedAt,
|
|
48
|
+
jobs,
|
|
49
|
+
board: path.relative(repoRoot, boardDir),
|
|
50
|
+
window_end: new Date(deadline).toISOString(),
|
|
51
|
+
adapter: adapter.name,
|
|
52
|
+
},
|
|
53
|
+
null,
|
|
54
|
+
2
|
|
55
|
+
) + '\n'
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const stopped = () => fs.existsSync(fleetPath(repoRoot, 'stop.flag'));
|
|
59
|
+
const results = [];
|
|
60
|
+
|
|
61
|
+
async function worker(id) {
|
|
62
|
+
const wid = `w${id}`;
|
|
63
|
+
const statePath = fleetPath(repoRoot, 'workers', `${wid}.json`);
|
|
64
|
+
const setState = (s) =>
|
|
65
|
+
fs.writeFileSync(statePath, JSON.stringify({ id: wid, ...s, ts: new Date().toISOString() }, null, 2));
|
|
66
|
+
setState({ phase: 'starting' });
|
|
67
|
+
|
|
68
|
+
while (true) {
|
|
69
|
+
if (stopped()) { setState({ phase: 'stopped' }); return; }
|
|
70
|
+
if (Date.now() >= deadline) { setState({ phase: 'window-ended' }); return; }
|
|
71
|
+
|
|
72
|
+
const task = board.claimNext(boardDir, wid, new Date().toISOString());
|
|
73
|
+
if (!task) {
|
|
74
|
+
if (board.boardCounts(boardDir).todo === 0) { setState({ phase: 'board-empty' }); return; }
|
|
75
|
+
await sleep(idleWaitMs);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const branch = wt.branchFor(task.name.replace(/\.md$/, ''));
|
|
79
|
+
const workDir = wt.createWorktree(repoRoot, wid, branch);
|
|
80
|
+
setState({ phase: 'running', task: task.name, branch, attempts: 0 });
|
|
81
|
+
onEvent('claimed', { worker: wid, task: task.name, attempts: 1 });
|
|
82
|
+
|
|
83
|
+
let attempts = 0;
|
|
84
|
+
let summary = '';
|
|
85
|
+
let lastExit = null;
|
|
86
|
+
let finalStatus = 'abandoned'; // stopped/window ended mid-task
|
|
87
|
+
let lastErr = '';
|
|
88
|
+
|
|
89
|
+
while (true) {
|
|
90
|
+
if (stopped() || Date.now() >= deadline) break; //leave in-progress, report shows it
|
|
91
|
+
attempts += 1;
|
|
92
|
+
board.setTaskStatus(task, 'in-progress', { attempts, branch });
|
|
93
|
+
const inv = adapter.cmdFor(task, cfg);
|
|
94
|
+
const res = await runCapture(inv.cmd, {
|
|
95
|
+
cwd: workDir,
|
|
96
|
+
input: inv.stdin,
|
|
97
|
+
timeoutMs: taskTimeoutMs,
|
|
98
|
+
env: { ...process.env, AWAYTEAM_WORKER: wid },
|
|
99
|
+
});
|
|
100
|
+
setState({ phase: 'running', task: task.name, branch, attempts, agent_pid: res.pid });
|
|
101
|
+
lastExit = res.exitCode;
|
|
102
|
+
|
|
103
|
+
const cls = classifyExit(res);
|
|
104
|
+
let parsed = { ok: false, summary: '' };
|
|
105
|
+
if (cls === 'success') {
|
|
106
|
+
try { parsed = adapter.parse(res); } catch (err) { parsed = { ok: false, summary: `parse error: ${err.message}` }; }
|
|
107
|
+
if (parsed.ok) {
|
|
108
|
+
summary = String(parsed.summary || '').slice(0, 200);
|
|
109
|
+
board.setTaskStatus(task, 'done', { attempts, branch, exit_code: 0, summary });
|
|
110
|
+
onEvent('done', { worker: wid, task: task.name, branch, attempts });
|
|
111
|
+
callNotify(cfg, { event: 'task-done', task: task.name, worker: wid, branch });
|
|
112
|
+
finalStatus = 'done';
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if ((cls === 'rate-limit' || parsed.retryable) && attempts < maxAttempts && !stopped() && Date.now() < deadline) {
|
|
118
|
+
const delay = Math.min(backoffBaseMs * 2 ** (attempts - 1), backoffCapMs);
|
|
119
|
+
setState({ phase: 'backoff', task: task.name, branch, attempts, resume_in_ms: delay });
|
|
120
|
+
onEvent('backoff', { worker: wid, task: task.name, attempts, delay_ms: delay });
|
|
121
|
+
await sleep(delay);
|
|
122
|
+
continue; // same task, same worktree — the agent can pick up where it left off
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lastErr = `${res.stderr || res.stdout || 'no output'}`.trim().split('\n').slice(-8).join('\n').slice(0, 800);
|
|
126
|
+
board.setTaskStatus(task, 'failed', {
|
|
127
|
+
attempts,
|
|
128
|
+
branch,
|
|
129
|
+
exit_code: lastExit ?? 'n/a',
|
|
130
|
+
error: cls,
|
|
131
|
+
detail: lastErr.slice(0, 200),
|
|
132
|
+
});
|
|
133
|
+
onEvent('failed', { worker: wid, task: task.name, branch, attempts, reason: cls, exit_code: lastExit });
|
|
134
|
+
callNotify(cfg, { event: 'task-failed', task: task.name, worker: wid, branch, reason: cls });
|
|
135
|
+
finalStatus = cls === 'rate-limit' ? 'failed-rate-limit' : 'failed';
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (finalStatus === 'abandoned') {
|
|
140
|
+
// stopped or window ended mid-task — leave it in-progress for the next run
|
|
141
|
+
onEvent('abandoned', { worker: wid, task: task.name, branch, attempts });
|
|
142
|
+
}
|
|
143
|
+
setState({ phase: 'idle', task: task.name });
|
|
144
|
+
results.push({
|
|
145
|
+
task: task.name,
|
|
146
|
+
pack: task.pack,
|
|
147
|
+
worker: wid,
|
|
148
|
+
branch,
|
|
149
|
+
status: finalStatus,
|
|
150
|
+
attempts,
|
|
151
|
+
exit_code: lastExit,
|
|
152
|
+
summary,
|
|
153
|
+
error: lastErr,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const ids = Array.from({ length: jobs }, (_, i) => i + 1);
|
|
159
|
+
await Promise.all(ids.map((i) => worker(i)));
|
|
160
|
+
|
|
161
|
+
const run = {
|
|
162
|
+
started_at: startedAt,
|
|
163
|
+
ended_at: new Date().toISOString(),
|
|
164
|
+
jobs,
|
|
165
|
+
adapter: adapter.name,
|
|
166
|
+
board: path.relative(repoRoot, boardDir),
|
|
167
|
+
start_head: startHead,
|
|
168
|
+
results,
|
|
169
|
+
};
|
|
170
|
+
const runFile = fleetPath(repoRoot, 'runs', `${startedAt.replace(/[:.]/g, '-')}.json`);
|
|
171
|
+
fs.writeFileSync(runFile, JSON.stringify(run, null, 2) + '\n');
|
|
172
|
+
|
|
173
|
+
fs.rmSync(fleetPath(repoRoot, 'workers'), { recursive: true, force: true });
|
|
174
|
+
try { fs.unlinkSync(fleetPath(repoRoot, 'fleet.json')); } catch (err) { /* already gone */ }
|
|
175
|
+
|
|
176
|
+
// Worktree dirs are per-run scratch; the branches (and commits) live on.
|
|
177
|
+
for (let i = 1; i <= jobs; i++) wt.removeWorktree(repoRoot, wt.wtPath(repoRoot, `w${i}`));
|
|
178
|
+
try { fs.rmdirSync(fleetPath(repoRoot, 'wt')); } catch (err) { /* still has something — leave it */ }
|
|
179
|
+
|
|
180
|
+
return run;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { runFleet };
|
package/lib/worktree.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// worktree.js — git worktree + branch per worker. Every agent works in its
|
|
2
|
+
// own checkout on its own branch; nothing touches the parent tree and nothing
|
|
3
|
+
// merges for you. The morning report hands you the branches, you do the merge.
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
function git(repoRoot, args) {
|
|
9
|
+
return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function assertRepo(repoRoot) {
|
|
13
|
+
try {
|
|
14
|
+
git(repoRoot, ['rev-parse', '--is-inside-work-tree']);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new Error(`Not a git repository: ${repoRoot}`);
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
git(repoRoot, ['rev-parse', 'HEAD']);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
throw new Error('Repository has no commits — commit something first.');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function wtPath(repoRoot, name) {
|
|
26
|
+
return path.join(repoRoot, '.awayteam', 'wt', name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function branchFor(slug) {
|
|
30
|
+
return `away/${slug}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Creates (or resets) a worktree checked out on `branch`, freshly based on the
|
|
34
|
+
// current HEAD. -B resets the branch if it exists, so retries start clean.
|
|
35
|
+
function createWorktree(repoRoot, name, branch) {
|
|
36
|
+
const dir = wtPath(repoRoot, name);
|
|
37
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
38
|
+
if (fs.existsSync(dir)) {
|
|
39
|
+
try {
|
|
40
|
+
git(repoRoot, ['worktree', 'remove', '--force', dir]);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
43
|
+
try { git(repoRoot, ['worktree', 'prune']); } catch (e) { /* prune is best-effort */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
git(repoRoot, ['worktree', 'add', '-B', branch, dir, 'HEAD']);
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function removeWorktree(repoRoot, dir) {
|
|
51
|
+
if (!fs.existsSync(dir)) return;
|
|
52
|
+
try {
|
|
53
|
+
git(repoRoot, ['worktree', 'remove', '--force', dir]);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
try { git(repoRoot, ['worktree', 'prune']); } catch (e) { /* best-effort */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function commitLog(repoRoot, base, branch, limit = 10) {
|
|
61
|
+
try {
|
|
62
|
+
return git(repoRoot, ['log', '--oneline', `${base}..${branch}`, '-n', String(limit)]);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function diffStat(repoRoot, base, branch) {
|
|
69
|
+
try {
|
|
70
|
+
return git(repoRoot, ['diff', '--stat', `${base}..${branch}`]);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Branches that survived the run (they exist in the repo even after the
|
|
77
|
+
// worktree dir is gone).
|
|
78
|
+
function listBranches(repoRoot, prefix = 'away/') {
|
|
79
|
+
try {
|
|
80
|
+
return git(repoRoot, ['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`])
|
|
81
|
+
.split('\n')
|
|
82
|
+
.map((s) => s.trim())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { assertRepo, branchFor, commitLog, createWorktree, diffStat, listBranches, removeWorktree, wtPath };
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "awayteam",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Overnight coding-agent ops kit: run N agents on one machine, wake up to a report. Works with the agent CLIs you already have.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": { "awayteam": "bin/awayteam.js" },
|
|
7
|
+
"main": "lib/cli.js",
|
|
8
|
+
"scripts": { "test": "node --test test/*.test.js" },
|
|
9
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"keywords": ["ai", "agents", "automation", "cli", "overnight", "workflows"],
|
|
11
|
+
"files": ["bin", "lib", "README.md", "LICENSE"]
|
|
12
|
+
}
|