@tonoid/agent-loop 1.0.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 +378 -0
- package/briefs/default/build.md +35 -0
- package/briefs/default/core.md +52 -0
- package/briefs/default/journal.optional.md +7 -0
- package/briefs/default/review.md +65 -0
- package/briefs/default/routine.md +18 -0
- package/briefs/default/screenshots.optional.md +10 -0
- package/briefs/default/subagents.optional.md +7 -0
- package/docs/cutover.md +121 -0
- package/package.json +46 -0
- package/src/adapters/gh.ts +77 -0
- package/src/adapters/git.ts +82 -0
- package/src/adapters/herdr.ts +121 -0
- package/src/adapters/run.ts +64 -0
- package/src/adopt.ts +47 -0
- package/src/brief.ts +124 -0
- package/src/check.ts +126 -0
- package/src/cli-pause.ts +16 -0
- package/src/cli.ts +290 -0
- package/src/config.ts +211 -0
- package/src/ctx.ts +64 -0
- package/src/discover.ts +344 -0
- package/src/effects/monitor.ts +148 -0
- package/src/effects/spawn.ts +246 -0
- package/src/effects/sweep.ts +27 -0
- package/src/engine/item.ts +32 -0
- package/src/engine/monitor.ts +117 -0
- package/src/engine/naming.ts +45 -0
- package/src/engine/spawn.ts +101 -0
- package/src/engine/sweep.ts +91 -0
- package/src/engine/tick.ts +25 -0
- package/src/filing.ts +56 -0
- package/src/globalstate.ts +228 -0
- package/src/journal.ts +13 -0
- package/src/kinds/builder.ts +128 -0
- package/src/kinds/index.ts +13 -0
- package/src/kinds/reviewer.ts +220 -0
- package/src/kinds/routine.ts +174 -0
- package/src/kinds/shared.ts +49 -0
- package/src/kinds/validate.ts +187 -0
- package/src/lock.ts +63 -0
- package/src/paths.ts +21 -0
- package/src/render.ts +42 -0
- package/src/router/budget.ts +81 -0
- package/src/router/providers/claude.ts +177 -0
- package/src/router/providers/codex.ts +120 -0
- package/src/router/providers/grok.ts +10 -0
- package/src/router/rate.ts +62 -0
- package/src/router/route.ts +179 -0
- package/src/router/window.ts +39 -0
- package/src/runtime/worker.ts +75 -0
- package/src/state.ts +128 -0
- package/src/status.ts +41 -0
- package/src/types.ts +225 -0
package/docs/cutover.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Cutover
|
|
2
|
+
|
|
3
|
+
The command list. The reasoning behind the order is in the README.
|
|
4
|
+
|
|
5
|
+
Every phase is reversible. The bash script stays on disk and its cron line stays
|
|
6
|
+
one `#` from restoration until the last phase has survived a week.
|
|
7
|
+
|
|
8
|
+
## Phase 0: freeze what the old pipelines know
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
agent-loop check
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
It resolves `gh`, `git` and `herdr`, checks that `gh` is authenticated, warns
|
|
15
|
+
when herdr's protocol number is not the tested one, and validates every
|
|
16
|
+
discovered workspace. Give the existing bash directories a private git remote so
|
|
17
|
+
they are recoverable independently of the box, and delete worktrees whose pull
|
|
18
|
+
requests already closed: adopting a bash-era worktree is worth less than
|
|
19
|
+
deleting it.
|
|
20
|
+
|
|
21
|
+
## Phase 1: a shadow week
|
|
22
|
+
|
|
23
|
+
One cron line, no `--live`, its own log:
|
|
24
|
+
|
|
25
|
+
```cron
|
|
26
|
+
*/2 * * * * PATH=$HOME/.bun/bin:$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin flock -n ~/.agent-loop/tick.lock agent-loop tick >>~/.agent-loop/shadow.log 2>&1
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Spell `PATH` out: cron's own does not carry `bun`, so the command dies in its
|
|
30
|
+
shebang before the loop runs, and it does not carry `herdr` either, so a tick
|
|
31
|
+
that starts still fails every pass that touches an agent.
|
|
32
|
+
|
|
33
|
+
A tick without `--live` performs every read, refuses every write at the gate in
|
|
34
|
+
`src/adapters/run.ts`, and leaves its own marks database alone as well, so a
|
|
35
|
+
week of shadow ticks records nothing the loop did not do. The one thing it does
|
|
36
|
+
write is a usage sample per account per tick in the global database.
|
|
37
|
+
|
|
38
|
+
Two things the week does not buy, both following from the same fact that it
|
|
39
|
+
spawns nothing. The router's worker rate is not measured during it: a sample
|
|
40
|
+
needs at least one worker in flight over the interval, so the rate stays at
|
|
41
|
+
`workerRateSeed` until the first live day, and the pacing model has no evidence
|
|
42
|
+
behind it on the day it starts deciding. And because a dry tick's marks do not
|
|
43
|
+
survive it, a routine that is due stays due on every tick of the week; the
|
|
44
|
+
spawn pass keeps walking the jobs without `--live` for exactly that reason, so
|
|
45
|
+
every job still reports, but the first one reports the same occurrence all
|
|
46
|
+
week rather than a sequence of them.
|
|
47
|
+
|
|
48
|
+
Read it back with
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
grep -E "WOULD|SKIP|IDLE|ERROR" ~/.agent-loop/shadow.log
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
and compare against the bash logs for the same minutes. Only the ticks where
|
|
55
|
+
either side acted are worth reading. The criterion is a full week where the
|
|
56
|
+
intended decisions match line for line.
|
|
57
|
+
|
|
58
|
+
## Phase 2: the routines
|
|
59
|
+
|
|
60
|
+
A routine's occurrence stamp is the one piece of state that is not derived from
|
|
61
|
+
the outside world, so it is the one thing that has to be imported. Without it a
|
|
62
|
+
routine re-runs an occurrence the old pipeline already finished, and anything
|
|
63
|
+
with outbound side effects does that visibly.
|
|
64
|
+
|
|
65
|
+
For each routine, record the occurrence that is running right now:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
agent-loop adopt <job> --workspace <name>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
For an older occurrence, name its key, which is the slot's date and time:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
agent-loop adopt <job> 20260820-0910 --workspace <name>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Check the import, then check what the loop makes of it:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
agent-loop adopt --list --workspace <name>
|
|
81
|
+
agent-loop tick --workspace <name>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The spawn line for that job must read `IDLE <job>`, which is the loop saying the
|
|
85
|
+
occurrence is already accounted for. Anything else on that line, a
|
|
86
|
+
`WOULD spawn` or a `SKIP` naming an account or a cap, means the occurrence is
|
|
87
|
+
still due and the stamp you wrote is not the one the loop computes. Keys are the
|
|
88
|
+
slot as `<YYYYMMDD>-<HHMM>`, in the box's own timezone, and an occurrence runs
|
|
89
|
+
from its slot until the next one begins, so the occurrence running at 08:00 with
|
|
90
|
+
slots at 09:10 and 21:10 is yesterday's `21:10`.
|
|
91
|
+
|
|
92
|
+
Then comment out the bash cron line and watch two occurrences.
|
|
93
|
+
|
|
94
|
+
## Phase 3: the one live reviewer
|
|
95
|
+
|
|
96
|
+
Its state is labels plus worktrees keyed by pull request, both derived, so there
|
|
97
|
+
is nothing to import. Pause the phase in bash, and let this one run. Both are
|
|
98
|
+
installed, and exactly one is unpaused.
|
|
99
|
+
|
|
100
|
+
## Phase 4: the paused service
|
|
101
|
+
|
|
102
|
+
From a clean slate. Let its remaining workers finish or remove them by hand, so
|
|
103
|
+
the new system starts with no inherited worktrees.
|
|
104
|
+
|
|
105
|
+
## Phase 5: the remaining build and review phases
|
|
106
|
+
|
|
107
|
+
Same shape as phase 3, one job at a time.
|
|
108
|
+
|
|
109
|
+
## Stopping
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
agent-loop pause <job> --workspace <name> stop spawning that job
|
|
113
|
+
agent-loop pause --workspace <name> stop spawning anything here
|
|
114
|
+
agent-loop resume <job> --workspace <name>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Pause takes effect on the next tick and needs no crontab edit. Sweep and monitor
|
|
118
|
+
keep running while a job is paused, which is deliberate: the monitor is what
|
|
119
|
+
takes a claim label off finished work, and a pause that froze it would
|
|
120
|
+
manufacture claims that lie. To stop a workspace altogether, remove its path
|
|
121
|
+
from `workspaces` in `~/.agent-loop/config.yml`.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tonoid/agent-loop",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Schedules autonomous coding agents across provider accounts and runs them as herdr agents in herdr panes.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "melalj <3869766+melalj@users.noreply.github.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tonoid/agent-loop.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/tonoid/agent-loop#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tonoid/agent-loop/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"herdr",
|
|
17
|
+
"agent",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"codex",
|
|
20
|
+
"cron",
|
|
21
|
+
"scheduler",
|
|
22
|
+
"git-worktree",
|
|
23
|
+
"automation"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"agent-loop": "./src/cli.ts"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "bun test",
|
|
31
|
+
"test:live": "AGENT_LOOP_LIVE_HERDR=1 bun test test/integration/herdr-live.test.ts",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest",
|
|
36
|
+
"typescript": "^5"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"src",
|
|
40
|
+
"briefs",
|
|
41
|
+
"docs/cutover.md"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Runner } from "./run"
|
|
2
|
+
import type { WorkItem, ItemState } from "../types"
|
|
3
|
+
|
|
4
|
+
export interface ListArgs { repo: string; state: "open" | "all"; limit?: number; label?: string }
|
|
5
|
+
export interface Gh {
|
|
6
|
+
issueList(a: ListArgs): Promise<WorkItem[]>
|
|
7
|
+
prList(a: ListArgs): Promise<WorkItem[]>
|
|
8
|
+
prView(repo: string, ref: string, fields: string[]): Promise<any>
|
|
9
|
+
label(repo: string, kind: "issue" | "pr", number: number, o: { add?: string[]; remove?: string[] }): Promise<void>
|
|
10
|
+
comment(repo: string, kind: "issue" | "pr", number: number, body: string): Promise<void>
|
|
11
|
+
labelsOf(repo: string, kind: "issue" | "pr", number: number): Promise<string[]>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// `gh issue list` rejects headRefName, so the two field sets differ.
|
|
15
|
+
const PR_FIELDS = "number,title,state,headRefName,labels,url,createdAt"
|
|
16
|
+
const ISSUE_FIELDS = "number,title,state,labels,url,createdAt"
|
|
17
|
+
|
|
18
|
+
// `gh` defaults --limit to 30 and truncates silently past it, with no error
|
|
19
|
+
// and no marker in the output. A caller that omits limit still needs every
|
|
20
|
+
// claimed item, so we send a high explicit default rather than gh's default.
|
|
21
|
+
const DEFAULT_LIMIT = 1000
|
|
22
|
+
|
|
23
|
+
function toItem(prefix: "pr" | "issue", raw: any): WorkItem {
|
|
24
|
+
return {
|
|
25
|
+
id: `${prefix}:${raw.number}`,
|
|
26
|
+
number: raw.number,
|
|
27
|
+
title: raw.title ?? "",
|
|
28
|
+
state: (raw.state ?? "OPEN") as ItemState,
|
|
29
|
+
labels: (raw.labels ?? []).map((l: any) => l.name),
|
|
30
|
+
headRef: raw.headRefName,
|
|
31
|
+
url: raw.url,
|
|
32
|
+
createdAt: raw.createdAt,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Two runners, not one. The read verbs take --json and are parsed; the write
|
|
37
|
+
// verbs print the item's URL on success, so parsing their output as JSON
|
|
38
|
+
// throws AFTER the write has already landed. That failure mode is the worst
|
|
39
|
+
// available: the label is applied, the caller sees an error, and the item is
|
|
40
|
+
// left claimed by a spawn that then rolls back.
|
|
41
|
+
export function makeGh(run: Runner, runText: (argv: string[]) => Promise<string>): Gh {
|
|
42
|
+
const listArgv = (kind: "pr" | "issue", a: ListArgs) => {
|
|
43
|
+
const fields = kind === "pr" ? PR_FIELDS : ISSUE_FIELDS
|
|
44
|
+
const argv = ["gh", kind, "list", "--repo", a.repo, "--state", a.state, "--json", fields]
|
|
45
|
+
argv.push("--limit", String(a.limit ?? DEFAULT_LIMIT))
|
|
46
|
+
if (a.label) argv.push("--label", a.label)
|
|
47
|
+
return argv
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
async issueList(a) {
|
|
51
|
+
const raw = await run(listArgv("issue", a))
|
|
52
|
+
return (raw as any[]).map((r) => toItem("issue", r))
|
|
53
|
+
},
|
|
54
|
+
async prList(a) {
|
|
55
|
+
const raw = await run(listArgv("pr", a))
|
|
56
|
+
return (raw as any[]).map((r) => toItem("pr", r))
|
|
57
|
+
},
|
|
58
|
+
async prView(repo, ref, fields) {
|
|
59
|
+
return run(["gh", "pr", "view", ref, "--repo", repo, "--json", fields.join(",")])
|
|
60
|
+
},
|
|
61
|
+
async label(repo, kind, number, o) {
|
|
62
|
+
const argv = [kind, "edit", String(number), "--repo", repo]
|
|
63
|
+
if (o.add?.length) argv.push("--add-label", o.add.join(","))
|
|
64
|
+
if (o.remove?.length) argv.push("--remove-label", o.remove.join(","))
|
|
65
|
+
// Nothing to change is not an error, and an edit with neither flag is.
|
|
66
|
+
if (argv.length === 5) return
|
|
67
|
+
await runText(["gh", ...argv])
|
|
68
|
+
},
|
|
69
|
+
async comment(repo, kind, number, body) {
|
|
70
|
+
await runText(["gh", kind, "comment", String(number), "--repo", repo, "--body", body])
|
|
71
|
+
},
|
|
72
|
+
async labelsOf(repo, kind, number) {
|
|
73
|
+
const r = await run(["gh", kind, "view", String(number), "--repo", repo, "--json", "labels"])
|
|
74
|
+
return ((r as any)?.labels ?? []).map((l: any) => l.name)
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface Worktree { path: string; branch: string | null }
|
|
2
|
+
export interface Git {
|
|
3
|
+
worktrees(): Promise<Worktree[]>
|
|
4
|
+
remoteSlug(): Promise<string>
|
|
5
|
+
lsRemote(pattern: string): Promise<string[]>
|
|
6
|
+
fetch(): Promise<void>
|
|
7
|
+
worktreeAdd(path: string, branch: string, base: string): Promise<void>
|
|
8
|
+
worktreeRemove(path: string): Promise<void>
|
|
9
|
+
branchDelete(branch: string): Promise<void>
|
|
10
|
+
remoteDelete(branch: string): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseWorktrees(porcelain: string): Worktree[] {
|
|
14
|
+
const out: Worktree[] = []
|
|
15
|
+
let path: string | null = null
|
|
16
|
+
let branch: string | null = null
|
|
17
|
+
const flush = () => {
|
|
18
|
+
if (path !== null) out.push({ path, branch })
|
|
19
|
+
path = null
|
|
20
|
+
branch = null
|
|
21
|
+
}
|
|
22
|
+
for (const line of porcelain.split("\n")) {
|
|
23
|
+
if (line.startsWith("worktree ")) {
|
|
24
|
+
flush()
|
|
25
|
+
path = line.slice("worktree ".length)
|
|
26
|
+
} else if (line.startsWith("branch refs/heads/")) {
|
|
27
|
+
branch = line.slice("branch refs/heads/".length)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
flush()
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// "owner/name" from any origin form: ssh, https, with or without .git. A job
|
|
35
|
+
// names a local path, and `gh` wants a slug; deriving it beats configuring the
|
|
36
|
+
// same repository twice and getting the two out of step.
|
|
37
|
+
export function slugFromRemote(url: string): string {
|
|
38
|
+
const m = url.trim().replace(/\.git$/, "").match(/([^/:]+\/[^/]+)$/)
|
|
39
|
+
if (!m) throw new Error(`cannot read an owner/name out of origin "${url.trim()}"`)
|
|
40
|
+
return m[1]!
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function makeGit(runText: (argv: string[]) => Promise<string>, repo: string): Git {
|
|
44
|
+
return {
|
|
45
|
+
async worktrees() {
|
|
46
|
+
return parseWorktrees(await runText(["git", "-C", repo, "worktree", "list", "--porcelain"]))
|
|
47
|
+
},
|
|
48
|
+
async remoteSlug() {
|
|
49
|
+
return slugFromRemote(await runText(["git", "-C", repo, "remote", "get-url", "origin"]))
|
|
50
|
+
},
|
|
51
|
+
async lsRemote(pattern) {
|
|
52
|
+
const out = await runText(["git", "-C", repo, "ls-remote", "--heads", "origin", pattern])
|
|
53
|
+
const prefix = "refs/heads/"
|
|
54
|
+
return out
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.map((l) => {
|
|
58
|
+
const i = l.indexOf(prefix)
|
|
59
|
+
return i === -1 ? "" : l.slice(i + prefix.length)
|
|
60
|
+
})
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
},
|
|
63
|
+
async fetch() {
|
|
64
|
+
await runText(["git", "-C", repo, "fetch", "origin", "--prune"])
|
|
65
|
+
},
|
|
66
|
+
async worktreeAdd(path, branch, base) {
|
|
67
|
+
await runText(["git", "-C", repo, "worktree", "add", "-b", branch, path, base])
|
|
68
|
+
},
|
|
69
|
+
async worktreeRemove(path) {
|
|
70
|
+
// --force because a worker leaves build output behind and a worktree
|
|
71
|
+
// with untracked files is refused otherwise; the sweep has already
|
|
72
|
+
// established that this worktree is finished with.
|
|
73
|
+
await runText(["git", "-C", repo, "worktree", "remove", "--force", path])
|
|
74
|
+
},
|
|
75
|
+
async branchDelete(branch) {
|
|
76
|
+
await runText(["git", "-C", repo, "branch", "-D", branch])
|
|
77
|
+
},
|
|
78
|
+
async remoteDelete(branch) {
|
|
79
|
+
await runText(["git", "-C", repo, "push", "origin", "--delete", branch])
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Runner } from "./run"
|
|
2
|
+
import type { AgentView, AgentStatus } from "../types"
|
|
3
|
+
|
|
4
|
+
export interface PaneView { cwd: string; paneId: string; tabId: string }
|
|
5
|
+
export interface WorkspaceView { id: string; label: string }
|
|
6
|
+
|
|
7
|
+
export interface TabCreateArgs {
|
|
8
|
+
workspaceId: string
|
|
9
|
+
cwd: string
|
|
10
|
+
label: string
|
|
11
|
+
env: Record<string, string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AgentStartArgs {
|
|
15
|
+
pane: string
|
|
16
|
+
kind: string
|
|
17
|
+
name: string
|
|
18
|
+
args: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Herdr {
|
|
22
|
+
agents(): Promise<AgentView[]>
|
|
23
|
+
panes(): Promise<PaneView[]>
|
|
24
|
+
protocol(): Promise<number>
|
|
25
|
+
workspaces(): Promise<WorkspaceView[]>
|
|
26
|
+
tabCreate(o: TabCreateArgs): Promise<void>
|
|
27
|
+
tabClose(tabId: string): Promise<void>
|
|
28
|
+
agentStart(o: AgentStartArgs): Promise<void>
|
|
29
|
+
agentPrompt(target: string, text: string, o?: { until?: string; timeoutMs?: number }): Promise<void>
|
|
30
|
+
agentSendKeys(target: string, keys: string[]): Promise<void>
|
|
31
|
+
agentRead(target: string, lines: number): Promise<string>
|
|
32
|
+
agentStatus(target: string): Promise<AgentStatus>
|
|
33
|
+
notify(title: string, body: string): Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type HerdrRead = Herdr
|
|
37
|
+
|
|
38
|
+
// The herdr protocol this project was built and tested against. Section 9 of
|
|
39
|
+
// the spec: check it, warn loudly on a mismatch, never refuse to run.
|
|
40
|
+
export const TESTED_PROTOCOL = 19
|
|
41
|
+
|
|
42
|
+
const KNOWN: AgentStatus[] = ["working", "blocked", "idle"]
|
|
43
|
+
|
|
44
|
+
function toStatus(s: unknown): AgentStatus {
|
|
45
|
+
return KNOWN.includes(s as AgentStatus) ? (s as AgentStatus) : "missing"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function makeHerdr(run: Runner): Herdr {
|
|
49
|
+
return {
|
|
50
|
+
async agents() {
|
|
51
|
+
const r = await run(["herdr", "agent", "list"])
|
|
52
|
+
return (r?.result?.agents ?? []).map((a: any) => ({
|
|
53
|
+
cwd: a.cwd,
|
|
54
|
+
status: toStatus(a.agent_status),
|
|
55
|
+
paneId: a.pane_id,
|
|
56
|
+
}))
|
|
57
|
+
},
|
|
58
|
+
async panes() {
|
|
59
|
+
const r = await run(["herdr", "pane", "list"])
|
|
60
|
+
return (r?.result?.panes ?? []).map((p: any) => ({
|
|
61
|
+
cwd: p.cwd,
|
|
62
|
+
paneId: p.pane_id,
|
|
63
|
+
tabId: p.tab_id,
|
|
64
|
+
}))
|
|
65
|
+
},
|
|
66
|
+
async protocol() {
|
|
67
|
+
const r = await run(["herdr", "api", "schema", "--json"])
|
|
68
|
+
return r?.protocol ?? r?.result?.protocol ?? -1
|
|
69
|
+
},
|
|
70
|
+
async workspaces() {
|
|
71
|
+
const r = await run(["herdr", "workspace", "list"])
|
|
72
|
+
return (r?.result?.workspaces ?? []).map((w: any) => ({
|
|
73
|
+
id: w.workspace_id,
|
|
74
|
+
label: w.label,
|
|
75
|
+
}))
|
|
76
|
+
},
|
|
77
|
+
async tabCreate(o) {
|
|
78
|
+
const argv = [
|
|
79
|
+
"herdr", "tab", "create",
|
|
80
|
+
"--workspace", o.workspaceId,
|
|
81
|
+
"--cwd", o.cwd,
|
|
82
|
+
"--label", o.label,
|
|
83
|
+
]
|
|
84
|
+
// tab create is the only verb that accepts --env, and it is therefore
|
|
85
|
+
// the sole channel by which the router's account choice reaches a worker.
|
|
86
|
+
for (const [k, v] of Object.entries(o.env)) argv.push("--env", `${k}=${v}`)
|
|
87
|
+
argv.push("--no-focus")
|
|
88
|
+
await run(argv)
|
|
89
|
+
},
|
|
90
|
+
async tabClose(tabId) {
|
|
91
|
+
await run(["herdr", "tab", "close", tabId])
|
|
92
|
+
},
|
|
93
|
+
async agentStart(o) {
|
|
94
|
+
const argv = ["herdr", "agent", "start", o.name, "--kind", o.kind, "--pane", o.pane]
|
|
95
|
+
if (o.args.length > 0) argv.push("--", ...o.args)
|
|
96
|
+
await run(argv)
|
|
97
|
+
},
|
|
98
|
+
async agentPrompt(target, text, o) {
|
|
99
|
+
const argv = ["herdr", "agent", "prompt", target, text]
|
|
100
|
+
if (o?.until) argv.push("--wait", "--until", o.until)
|
|
101
|
+
if (o?.timeoutMs !== undefined) argv.push("--timeout", String(o.timeoutMs))
|
|
102
|
+
await run(argv)
|
|
103
|
+
},
|
|
104
|
+
async agentSendKeys(target, keys) {
|
|
105
|
+
await run(["herdr", "agent", "send-keys", target, ...keys])
|
|
106
|
+
},
|
|
107
|
+
async agentRead(target, lines) {
|
|
108
|
+
const r = await run([
|
|
109
|
+
"herdr", "agent", "read", target, "--source", "recent-unwrapped", "--lines", String(lines),
|
|
110
|
+
])
|
|
111
|
+
return String(r?.result?.output ?? "")
|
|
112
|
+
},
|
|
113
|
+
async agentStatus(target) {
|
|
114
|
+
const r = await run(["herdr", "agent", "get", target])
|
|
115
|
+
return toStatus(r?.result?.agent?.agent_status)
|
|
116
|
+
},
|
|
117
|
+
async notify(title, body) {
|
|
118
|
+
await run(["herdr", "notification", "show", title, "--body", body, "--sound", "request"])
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type Runner = (argv: string[]) => Promise<any>
|
|
2
|
+
|
|
3
|
+
// Every read the loop performs, as an argv prefix. Git's "-C <repo>" is
|
|
4
|
+
// stripped before matching, so only the verb matters here. Anything absent
|
|
5
|
+
// from this list is a mutation as far as a dry run is concerned, including
|
|
6
|
+
// commands nobody has thought of yet: refusing the unknown is what makes the
|
|
7
|
+
// read-only claim hold when a later plan adds a verb and forgets this file.
|
|
8
|
+
const READS: string[][] = [
|
|
9
|
+
["gh", "auth", "status"],
|
|
10
|
+
["gh", "issue", "list"],
|
|
11
|
+
["gh", "pr", "list"],
|
|
12
|
+
["gh", "pr", "view"],
|
|
13
|
+
["gh", "issue", "view"],
|
|
14
|
+
["gh", "api"],
|
|
15
|
+
["git", "worktree", "list"],
|
|
16
|
+
["git", "for-each-ref"],
|
|
17
|
+
["git", "ls-remote"],
|
|
18
|
+
["git", "remote", "get-url"],
|
|
19
|
+
["herdr", "agent", "list"],
|
|
20
|
+
["herdr", "agent", "read"],
|
|
21
|
+
["herdr", "agent", "get"],
|
|
22
|
+
["herdr", "pane", "list"],
|
|
23
|
+
["herdr", "workspace", "list"],
|
|
24
|
+
["herdr", "api", "schema"],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
// `gh api` defaults to GET but switches to implicit POST when request parameters
|
|
28
|
+
// are added via -f / -F / --input. Any explicit method flag also makes it a write.
|
|
29
|
+
// The flag set covers both explicit methods and parameter flags that trigger POST.
|
|
30
|
+
const GH_API_WRITE = new Set(["-X", "--method", "-f", "--raw-field", "-F", "--field", "--input"])
|
|
31
|
+
|
|
32
|
+
function normalize(argv: string[]): string[] {
|
|
33
|
+
if (argv[0] === "git" && argv[1] === "-C") return ["git", ...argv.slice(3)]
|
|
34
|
+
return argv
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function assertReadOnly(argv: string[]): void {
|
|
38
|
+
const a = normalize(argv)
|
|
39
|
+
const allowed = READS.some((prefix) => prefix.every((word, i) => a[i] === word))
|
|
40
|
+
const ghApiWrite = a[0] === "gh" && a[1] === "api" && a.some((w) => GH_API_WRITE.has(w))
|
|
41
|
+
if (!allowed || ghApiWrite) {
|
|
42
|
+
throw new Error(`refusing to run "${argv.join(" ")}" without --live`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function spawnText(argv: string[]): Promise<string> {
|
|
47
|
+
const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" })
|
|
48
|
+
const [out, err, code] = await Promise.all([
|
|
49
|
+
new Response(proc.stdout).text(),
|
|
50
|
+
new Response(proc.stderr).text(),
|
|
51
|
+
proc.exited,
|
|
52
|
+
])
|
|
53
|
+
if (code !== 0) throw new Error(`${argv[0]} exited ${code}: ${err.trim()}`)
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function makeRunners(live: boolean) {
|
|
58
|
+
const runText = async (argv: string[]): Promise<string> => {
|
|
59
|
+
if (!live) assertReadOnly(argv)
|
|
60
|
+
return spawnText(argv)
|
|
61
|
+
}
|
|
62
|
+
const runJson = async <T,>(argv: string[]): Promise<T> => JSON.parse(await runText(argv)) as T
|
|
63
|
+
return { runText, runJson }
|
|
64
|
+
}
|
package/src/adopt.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Ctx, Job, Marks, WorkItem } from "./types"
|
|
2
|
+
import type { MarkRow } from "./state"
|
|
3
|
+
|
|
4
|
+
// A routine's key() falls back to the occurrence due right now when the item
|
|
5
|
+
// carries no key of its own, which is exactly the stamp a cutover imports.
|
|
6
|
+
const BLANK: WorkItem = { id: "", number: 0, title: "", state: "OPEN", labels: [] }
|
|
7
|
+
|
|
8
|
+
// The cutover's one import path (docs/cutover.md phase 2). A routine whose
|
|
9
|
+
// done-marker lived in a file re-runs on a fresh state directory, and for
|
|
10
|
+
// anything with outbound side effects that is not an acceptable cutover risk,
|
|
11
|
+
// so the stamp the loop would have written gets written by hand instead.
|
|
12
|
+
//
|
|
13
|
+
// The marks handle is passed rather than taken from the context because a
|
|
14
|
+
// context built without --live has a dryMarks overlay in ctx.marks, and this
|
|
15
|
+
// command's whole purpose is to write.
|
|
16
|
+
export async function adopt(
|
|
17
|
+
ctx: Ctx,
|
|
18
|
+
marks: Marks,
|
|
19
|
+
job: Job,
|
|
20
|
+
key?: string,
|
|
21
|
+
): Promise<{ key: string; already: boolean }> {
|
|
22
|
+
const k = key ?? (await currentKey(ctx, job))
|
|
23
|
+
const already = marks.has(job.name, k, "spawned")
|
|
24
|
+
if (!already) marks.set(job.name, k, "spawned")
|
|
25
|
+
return { key: k, already }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function currentKey(ctx: Ctx, job: Job): Promise<string> {
|
|
29
|
+
// Every other kind keys on an item, so there is no key it would use right
|
|
30
|
+
// now. Guessing one would stamp a real issue or pull request as done.
|
|
31
|
+
if (job.workload !== "routine") {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`job "${job.name}" is a ${job.workload}, which keys on an item: name the key to adopt`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
const k = await job.key(ctx, BLANK)
|
|
37
|
+
if (!k) throw new Error(`job "${job.name}" has no occurrence due right now`)
|
|
38
|
+
return k
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderMarks(rows: MarkRow[], now: Date): string[] {
|
|
42
|
+
if (!rows.length) return ["no marks recorded"]
|
|
43
|
+
return rows.map((r) => {
|
|
44
|
+
const minutes = Math.max(0, Math.round((now.getTime() - r.at) / 60000))
|
|
45
|
+
return `${r.job} ${r.key} ${r.mark} ${minutes}m ago`
|
|
46
|
+
})
|
|
47
|
+
}
|
package/src/brief.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import type { Ctx, Job, WorkItem } from "./types"
|
|
3
|
+
import { ghRepo } from "./engine/item"
|
|
4
|
+
import { worktreePath, branchName } from "./engine/naming"
|
|
5
|
+
|
|
6
|
+
export type Vars = Record<string, string | number>
|
|
7
|
+
|
|
8
|
+
export interface BriefLayers {
|
|
9
|
+
// A role file under briefs/, named the way job.yml names it: "default/build".
|
|
10
|
+
extends?: string
|
|
11
|
+
// Shipped opt-in sections, by bare name: briefs/default/<name>.optional.md.
|
|
12
|
+
optional?: string[]
|
|
13
|
+
// The user's own prose, already an absolute path by the time the loader is done.
|
|
14
|
+
append?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Beside the sources, so a checkout carries its briefs and no install step
|
|
18
|
+
// copies them anywhere.
|
|
19
|
+
export const BRIEFS_DIR = `${import.meta.dir}/../briefs`
|
|
20
|
+
|
|
21
|
+
// Two segments of the safe alphabet. A name reaches the filesystem, so this is
|
|
22
|
+
// the whole defence against "../../etc/passwd" arriving from a job.yml.
|
|
23
|
+
const BRIEF_NAME = /^[a-z0-9-]+\/[a-z0-9-]+$/
|
|
24
|
+
|
|
25
|
+
export function briefPath(name: string): string {
|
|
26
|
+
if (!BRIEF_NAME.test(name)) {
|
|
27
|
+
throw new Error(`brief.extends "${name}" must look like default/build`)
|
|
28
|
+
}
|
|
29
|
+
const path = `${BRIEFS_DIR}/${name}.md`
|
|
30
|
+
if (!existsSync(path)) throw new Error(`brief.extends "${name}" names no shipped brief`)
|
|
31
|
+
return path
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function optionalPath(name: string): string {
|
|
35
|
+
if (!/^[a-z0-9-]+$/.test(name)) throw new Error(`optional brief section "${name}" is not a name`)
|
|
36
|
+
const path = `${BRIEFS_DIR}/default/${name}.optional.md`
|
|
37
|
+
if (!existsSync(path)) throw new Error(`optional brief section "${name}" names no shipped file`)
|
|
38
|
+
return path
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Append, never replace: the fences in core.md are the reason an unattended
|
|
42
|
+
// worker is safe, and a user who could replace them would ship an agent that
|
|
43
|
+
// force-pushes (spec 8).
|
|
44
|
+
export function loadBrief(l: BriefLayers): string {
|
|
45
|
+
const parts = [readFileSync(`${BRIEFS_DIR}/default/core.md`, "utf8")]
|
|
46
|
+
if (l.extends) parts.push(readFileSync(briefPath(l.extends), "utf8"))
|
|
47
|
+
for (const name of l.optional ?? []) parts.push(readFileSync(optionalPath(name), "utf8"))
|
|
48
|
+
if (l.append) {
|
|
49
|
+
if (!existsSync(l.append)) throw new Error(`brief.append "${l.append}" does not exist`)
|
|
50
|
+
parts.push(readFileSync(l.append, "utf8"))
|
|
51
|
+
}
|
|
52
|
+
return parts.map((p) => p.trim()).join("\n\n") + "\n"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const VAR = /\{\{([a-zA-Z0-9_.]+)\}\}/g
|
|
56
|
+
|
|
57
|
+
// Plain substitution and nothing else. A brief is prose, and a template engine
|
|
58
|
+
// that can branch is a dependency and a second language to debug at 3am.
|
|
59
|
+
export function render(template: string, vars: Vars): string {
|
|
60
|
+
return template.replace(VAR, (whole, name: string) => {
|
|
61
|
+
const v = vars[name]
|
|
62
|
+
return v === undefined ? whole : String(v)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function unresolved(text: string): string[] {
|
|
67
|
+
return [...text.matchAll(VAR)].map((m) => m[1]!)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function briefVars(
|
|
71
|
+
ctx: Ctx,
|
|
72
|
+
job: Job,
|
|
73
|
+
item: WorkItem,
|
|
74
|
+
key: string,
|
|
75
|
+
extra: Vars = {},
|
|
76
|
+
): Promise<Vars> {
|
|
77
|
+
const labels = ctx.workspace.naming.labels
|
|
78
|
+
// The account is what the PR body's "built-by:" line carries, which is what
|
|
79
|
+
// makes distinctFrom (spec 6.4) derive from the outside world. The router has
|
|
80
|
+
// already reserved the row by the time a brief is rendered.
|
|
81
|
+
const account = ctx.global.accountFor(ctx.workspace.name, job.name, key) ?? "unknown"
|
|
82
|
+
return {
|
|
83
|
+
item: `#${item.number}`,
|
|
84
|
+
number: item.number,
|
|
85
|
+
title: item.title,
|
|
86
|
+
itemUrl: item.url ?? "",
|
|
87
|
+
key,
|
|
88
|
+
worktree: worktreePath(ctx.workspace.worktreeBase, job.name, key),
|
|
89
|
+
branch: branchName(job.name, key),
|
|
90
|
+
headRef: item.headRef ?? "",
|
|
91
|
+
base: job.base ? await job.base(ctx, item) : "",
|
|
92
|
+
attempt: job.attempt ? await job.attempt(ctx, item) : 1,
|
|
93
|
+
attemptCap: 0,
|
|
94
|
+
repoSlug: ghRepo(item) ?? "",
|
|
95
|
+
journal: ctx.workspace.journalPath,
|
|
96
|
+
mergeMethod: ctx.workspace.naming.mergeMethod,
|
|
97
|
+
// The kind that merges writes this whole step, because the template has no
|
|
98
|
+
// conditionals. The default is the fail-safe direction: a job that renders
|
|
99
|
+
// a merge step without saying how to merge is not told to merge.
|
|
100
|
+
mergeInstruction: "Do not merge. The merge is not yours to make, so stop here and say so.",
|
|
101
|
+
assetBranch: "assets",
|
|
102
|
+
"labels.claim": labels.claim,
|
|
103
|
+
"labels.failed": labels.failed,
|
|
104
|
+
"labels.park": labels.park,
|
|
105
|
+
filingBudget: 0,
|
|
106
|
+
openQueue: 0,
|
|
107
|
+
dedupeBy: "path",
|
|
108
|
+
account,
|
|
109
|
+
commentPrefix: "",
|
|
110
|
+
passLabel: "",
|
|
111
|
+
...extra,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function renderBrief(
|
|
116
|
+
ctx: Ctx,
|
|
117
|
+
job: Job,
|
|
118
|
+
item: WorkItem,
|
|
119
|
+
key: string,
|
|
120
|
+
layers: BriefLayers,
|
|
121
|
+
extra: Vars = {},
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
return render(loadBrief(layers), await briefVars(ctx, job, item, key, extra))
|
|
124
|
+
}
|