@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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/briefs/default/build.md +35 -0
  4. package/briefs/default/core.md +52 -0
  5. package/briefs/default/journal.optional.md +7 -0
  6. package/briefs/default/review.md +65 -0
  7. package/briefs/default/routine.md +18 -0
  8. package/briefs/default/screenshots.optional.md +10 -0
  9. package/briefs/default/subagents.optional.md +7 -0
  10. package/docs/cutover.md +121 -0
  11. package/package.json +46 -0
  12. package/src/adapters/gh.ts +77 -0
  13. package/src/adapters/git.ts +82 -0
  14. package/src/adapters/herdr.ts +121 -0
  15. package/src/adapters/run.ts +64 -0
  16. package/src/adopt.ts +47 -0
  17. package/src/brief.ts +124 -0
  18. package/src/check.ts +126 -0
  19. package/src/cli-pause.ts +16 -0
  20. package/src/cli.ts +290 -0
  21. package/src/config.ts +211 -0
  22. package/src/ctx.ts +64 -0
  23. package/src/discover.ts +344 -0
  24. package/src/effects/monitor.ts +148 -0
  25. package/src/effects/spawn.ts +246 -0
  26. package/src/effects/sweep.ts +27 -0
  27. package/src/engine/item.ts +32 -0
  28. package/src/engine/monitor.ts +117 -0
  29. package/src/engine/naming.ts +45 -0
  30. package/src/engine/spawn.ts +101 -0
  31. package/src/engine/sweep.ts +91 -0
  32. package/src/engine/tick.ts +25 -0
  33. package/src/filing.ts +56 -0
  34. package/src/globalstate.ts +228 -0
  35. package/src/journal.ts +13 -0
  36. package/src/kinds/builder.ts +128 -0
  37. package/src/kinds/index.ts +13 -0
  38. package/src/kinds/reviewer.ts +220 -0
  39. package/src/kinds/routine.ts +174 -0
  40. package/src/kinds/shared.ts +49 -0
  41. package/src/kinds/validate.ts +187 -0
  42. package/src/lock.ts +63 -0
  43. package/src/paths.ts +21 -0
  44. package/src/render.ts +42 -0
  45. package/src/router/budget.ts +81 -0
  46. package/src/router/providers/claude.ts +177 -0
  47. package/src/router/providers/codex.ts +120 -0
  48. package/src/router/providers/grok.ts +10 -0
  49. package/src/router/rate.ts +62 -0
  50. package/src/router/route.ts +179 -0
  51. package/src/router/window.ts +39 -0
  52. package/src/runtime/worker.ts +75 -0
  53. package/src/state.ts +128 -0
  54. package/src/status.ts +41 -0
  55. package/src/types.ts +225 -0
@@ -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
+ }