@miroshnik/elves 2026.1020.0 → 2026.1022.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/AGENTS.md CHANGED
@@ -15,7 +15,7 @@ repositories, no colleagues or clients — in files, git history or issues;
15
15
  examples are made up. **A skill is `SKILL.md` + `scripts/`** in TypeScript
16
16
  under Bun (Agent Skills; README, "Repository layout"); repository specifics
17
17
  — local configuration. **Budgets** (standard `canon-size`): this file ≤
18
- 3,000 words, a `SKILL.md` ≤ 2,500, a skill description ≤ 1,024 characters.
18
+ 3,000 words, `SKILL.md` ≤ 2,500, a description ≤ 1,024 characters.
19
19
  Here — what always applies and where to go; reference — a skill's
20
20
  `reference.md` or `--help`, `docs/`.
21
21
 
@@ -31,7 +31,7 @@ a release is the owner's step (`elves release`), I cut one only when asked.
31
31
  briefly what changed (it lists the skipped releases) and re-read what was
32
32
  updated: it applies, not what loaded at the start.
33
33
  - **The project is behind** (or git ignores flow files) — `update` in the
34
- task's branch: the package version, a few lines.
34
+ task's branch: the package version.
35
35
  - **The project keeps a copy of the flow** — the move is a task of its own,
36
36
  one per project (`check` prints its `github task new`): I recommend it and
37
37
  ask whether to queue it.
@@ -118,14 +118,15 @@ done. The session can be closed.` · `All done, but there are questions: …` ·
118
118
  regression and a fix for a green PR are the same task. A new epic — the
119
119
  question «run the dispatcher on epic #M?», without In progress.
120
120
  - **A dispatcher** — "do all tasks of epic #M", "fix all High bugs", a "yes"
121
- — any session, its task stays; one started for it — in the project folder,
122
- not a worktree: executors start from `main`. `github task queue` prints
123
- executors' prompts; an executor — a background subagent in its worktree or
124
- a cloud session: at most 3 subagents and 5 cloud sessions at once, the
125
- owner's number replaces either; failed — a task comment, no restart; an
126
- executor session whose task closed — the dispatcher archives it in an auto
127
- project, not one still working in the background or awaiting the owner's
128
- answer; outside auto the owner closes it.
121
+ (scope, not merges outside auto) — any session, its task stays; one started
122
+ for it — in the project folder, not a worktree: executors start from `main`.
123
+ `github task queue` prints executors' prompts; an executor — a background
124
+ subagent (own worktree) or a cloud session: at most 3 subagents and 5 cloud
125
+ sessions at once, the owner's number replaces either; failed — a task
126
+ comment, no restart; an executor session whose task closed — the dispatcher
127
+ archives it in an auto project, not one still working in the background or
128
+ awaiting the owner's answer; outside auto the owner closes it. A batch
129
+ project — `github batch release`.
129
130
  - **The session is named `#<number> <task title>`** (without a task — by
130
131
  meaning, no number) as soon as the task is clear (no tool — ask the user):
131
132
  `est` links the session to the task by it.
@@ -167,8 +168,8 @@ and worktrees") — otherwise there is no actual.
167
168
  ### Epic refresh on task close
168
169
 
169
170
  Once a task is merged, **a subagent with a fresh context** brings the open
170
- tasks of its epic in line with what shipped — not the session at peak
171
- context: `github task actualize <N>` does the mechanics and prints its prompt,
171
+ tasks of its epic in line with what shipped: `github task actualize <N>`
172
+ does the mechanics and prints its prompt,
172
173
  in the same turn as `ci-wait merged <PR>` in the background; after both —
173
174
  `github task close`. The last line of the answer ("Language"): merged,
174
175
  deployed, branch removed, epic refreshed, no questions — "all done, can be
@@ -197,11 +198,12 @@ it.
197
198
  rebase and `--force-with-lease` — only on a textual conflict or my own
198
199
  changes. **Before merging — `github pr premerge`:** `main` moved after the
199
200
  PR's CI — fast checks on the merge with it; red — I fix the root cause;
200
- `main` red by itself (exit code 3) — no merge and no fix in the PR: one bug
201
+ `main` red by itself (code 3) — no merge and no fix in the PR: one bug
201
202
  for it, I wait for it to close.
202
203
  - A merge that deploys — I wait for the deploy on the merge SHA (`ci-wait`)
203
- **before `github task close`**. Others' branches, stacks and unmerged work
204
- I don't touch.
204
+ **before `github task close`**; a batch's — its release's CI, deploy,
205
+ refresh.
206
+ Others' branches, stacks and unmerged work I don't touch.
205
207
  - Mechanics — the `github` skill, "Git, PRs and merging — mechanics";
206
208
  waiting for checks and deploys — `docs/pr-checks.md`.
207
209
 
@@ -283,8 +285,8 @@ skill (`canon.md`, `checks.md`).
283
285
  skill) at `<service>.<N>.<project>.localhost`, not on a host port; others'
284
286
  stacks and busy ports I don't touch — `docs/parallel-checkouts.md`.
285
287
  - **A subagent gets a unit of ~50 turns** (one decision's folder, one file,
286
- one spec), not a 200-turn zone: with the same ≈ 100k-token preamble its
287
- price is quadratic in turns. The assignment has the task
288
+ one spec), not a 200-turn zone: its price is quadratic in turns. The
289
+ assignment has the task
288
290
  number after the word for "task" or at the start, the epic after the word
289
291
  for "epic" ("Language") — so `est` links its work. **Fewer turns:** a file
290
292
  in one full read, independent tool calls in one turn.
package/README.md CHANGED
@@ -178,7 +178,7 @@ An agent's sandbox (Claude Code's Bash) doesn't let bunx write its cache `~/.bun
178
178
 
179
179
  `install` in a project asks in the terminal whether the agent should do tasks entirely by itself — answer the task's questions by recommendation, commit, push, fix CI and merge its own PR on green checks (default — no); without a terminal — `install --auto` or `--no-auto`, without the flag the previous answer stays. `check` names the mode. With auto, `install` prints a hint for Claude Code: the app rejects a merge without human review in this mode too, and the session merges its own PR by itself only with the `Bash(gh pr merge *)` rule in `permissions.allow` of `~/.claude/settings.json` — the owner adds it, `install` doesn't change the machine's permission settings.
180
180
 
181
- `install` also asks for the **project language** the agent works in: dialog, tasks, prompts, documents; the flow itself is written in English. It is stored in `.agents/elves.json` as `language` — a BCP 47 code (`ru`, `en`, `pt-BR`), `en` by default; an install of a previous version without the field counts as `ru`. Without a terminal — `install --lang <code>`, without the flag the previous language stays; `update` keeps it, `check` and the hook name it. The flow isn't installed into an Elves clone — `install` writes only these two settings there.
181
+ `install` also asks for the **project language** the agent works in: dialog, tasks, prompts, documents; the flow itself is written in English. It is stored in `.agents/elves.json` as `language` — a BCP 47 code (`ru`, `en`, `pt-BR`), `en` by default; an install of a previous version without the field counts as `ru`. Without a terminal — `install --lang <code>`, without the flag the previous language stays; `update` keeps it, `check` and the hook name it. The flow isn't installed into an Elves clone — `install` writes only its manifest there: these two settings, `env` and `batch` carried over as in a project.
182
182
 
183
183
  <details>
184
184
  <summary><b>What goes where</b></summary>
@@ -186,7 +186,7 @@ An agent's sandbox (Claude Code's Bash) doesn't let bunx write its cache `~/.bun
186
186
  **Into a project** — thin: no copy of the flow, its agents get the canon and the skills from the machine. Committed with the project:
187
187
 
188
188
  - `@miroshnik/elves` in `devDependencies` of `package.json` — the exact version of the release, the project's package manager updates its lockfile: the harness and the skills' scripts at the project's version for its tests and CI, colleagues and the cloud session ("The package in a project" below); a project without `package.json` has nothing to pin.
189
- - `.agents/elves.json` — `auto` — whether the agent runs a task entirely by itself, without a "yes" for commit, push and merge, `language` — the project language, and `env` — the task stack settings of `task-env`.
189
+ - `.agents/elves.json` — `auto` — whether the agent runs a task entirely by itself, without a "yes" for commit, push and merge, `language` — the project language, `env` — the task stack settings of `task-env`, and `batch` — whether a dispatcher merges its tasks into a batch branch instead of `main` (`github task queue`).
190
190
  - `.agents/slot.sh` — the machine queue's shim the `package.json` checks source (`slot` below).
191
191
  - A block at the top of the project's `AGENTS.md` linking to the canon on the machine, `~/.agents/elves/AGENTS.md` — for the agents that read the project's file.
192
192
  - The cloud session's `SessionStart` hook in `.claude/settings.json` (your own settings and hooks stay): in a Claude Code cloud session (`CLAUDE_CODE_REMOTE=true`) it installs a fresh Bun and the flow onto the session's machine and runs `check --hook`, outside the cloud it exits at once — no setup script in the environment is needed (`docs/cloud-sessions.md`).
@@ -258,9 +258,9 @@ Skills are installed onto the machine with the flow (`install -g`); the agent pi
258
258
 
259
259
  ### [`github`](skills/github/SKILL.md) — project and tasks by the canon
260
260
 
261
- Checks and fixes the GitHub project and the default-branch rule, creates a task with all its fields in one command, shows a task with its epic, blockers, PR checks and open questions in one output and puts an answer at a question, before merging checks the PR's merge with a fresh `main` if it moved after CI, after merging does the epic refresh's mechanics and prints the subagent's prompt, and closes the task in one call — `est` actual, Done, epic and milestone, the task's Compose stacks and its merged branches removed — sets decision labels from the PR diff, names the wait a resumed session restarts for its task, and gives a dispatcher its queue — the ready tasks of an epic or a filter in order, each with the prompt of its executor, a background subagent.
261
+ Checks and fixes the GitHub project and the default-branch rule, creates a task with all its fields in one command, shows a task with its epic, blockers, PR checks and open questions in one output and puts an answer at a question, before merging checks the PR's merge with a fresh `main` if it moved after CI, after merging does the epic refresh's mechanics and prints the subagent's prompt, and closes the task in one call — `est` actual, Done, epic and milestone, the task's Compose stacks and its merged branches removed — sets decision labels from the PR diff, names the wait a resumed session restarts for its task, and gives a dispatcher its queue — the ready tasks of an epic or a filter in order, each with the prompt of its executor, a background subagent; in a batch project the executors merge into the dispatcher's batch branch, and it reaches `main` by one release PR — one full CI run and one deploy.
262
262
 
263
- `github project check` · `github project fix` · `github task new | show | answer | status | drop | actualize | close | resume | queue` · `github pr labels | premerge`
263
+ `github project check` · `github project fix` · `github task new | show | answer | status | drop | actualize | close | resume | queue` · `github batch release` · `github pr labels | premerge`
264
264
 
265
265
  ### [`est`](skills/est/SKILL.md) — estimates from actuals
266
266
 
package/bin/ai-dev.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  * The installer under its name before the rename (#544) — `bin/elves.ts` itself: `update -g` of a release before the
3
3
  * rename pulls a `--link` clone and runs `<clone>/bin/ai-dev.ts install -g --link` with the clone's code, and this file
4
4
  * keeps that run working — the links, the SessionStart hook and the clone's origin come from the updated clone. Not a
5
- * command of the package (`bin` in package.json has only `elves`).
5
+ * command of the package (`bin` in package.json has only `elves`). `bin/elves.ts` runs its command line only as the
6
+ * entry point — here it is called.
6
7
  */
7
- import "./elves.ts";
8
+ import { main } from "./elves.ts";
9
+
10
+ process.exitCode = await main(process.argv.slice(2));
package/bin/elves.ts CHANGED
@@ -42,23 +42,25 @@
42
42
  * in a project — only `auto`: whether the agent runs a task entirely by itself — answers to questions by
43
43
  * recommendation, commit, push, fixing CI and merging its own PR on green checks (AGENTS.md, "What I do without asking
44
44
  * and what needs a yes"), `language` — the language the agent works in in the project: dialog, tasks, prompts,
45
- * documents (AGENTS.md, "Language"; the flow is written in English), and `env` — the task stack settings of the
46
- * task-env skill. `install` in a project sets auto and language — by a question in the terminal or by
45
+ * documents (AGENTS.md, "Language"; the flow is written in English), `env` — the task stack settings of the
46
+ * task-env skill, and `batch` — the dispatcher's batches (epic #645). `install` in a project sets auto and language — by a question in the terminal or by
47
47
  * the flags `--auto` / `--no-auto` and `--lang <code>`; by default auto is "no", the language is `en`, without a
48
48
  * terminal and a flag — the previous value, `update` keeps it. An install of a previous version has no `language`
49
49
  * field, and the flow before it worked only in Russian: its language is `ru` (`LEGACY_LANGUAGE` of the github skill's
50
50
  * language module), `update` writes it. With auto `install` suggests the Claude Code permission rule for the merge
51
51
  * (MERGE_HINT) — it doesn't change the machine's settings. The flow isn't installed into an Elves clone (the canon is
52
- * at its root): `install` writes a single manifest there, with `auto` and `language`.
52
+ * at its root): `install` writes a single manifest there, with `auto` and `language`, `env` and `batch` carried over as
53
+ * in a project; `check` there compares nothing but a batch clone's CI (`batchCi`).
53
54
  * The project's flow files are committed — the ones the project's git ignores (`.agents/`) the install un-ignores with
54
55
  * exceptions in an Elves block at the end of `.gitignore`, `check` reports them as a mismatch (`gitignoreFlow`). A
55
56
  * project's compose file `check` holds to the task isolation rules
56
57
  * with the task-env skill's command (`composeCheck`): a violation is a mismatch too, `update` doesn't fix it; so is a
57
58
  * project with standards or architecture without a `test:spec` script — `check` names the lines (`specScript`); a
58
- * `test` that runs them a second time next to `test:spec` — an `○` line with `test` without them. The
59
- * project's checks — the `package.json` scripts `lint`, `typecheck`, `test`, `test:*` — the install wraps in the
60
- * machine queue, the slot skill (`packageScripts`); if such a script calls turbo and turbo.json doesn't pass the queue
61
- * variables to tasks, it warns with a ready-made line (`turboEnv`).
59
+ * `test` that runs them a second time next to `test:spec` — an `○` line with `test` without them; a project with
60
+ * batches whose CI runs on a PR into a batch branch — a mismatch, `❌` lines (`batchCi`). The project's checks — the
61
+ * `package.json` scripts `lint`, `typecheck`, `test`, `test:*` — the install wraps in the machine queue, the slot skill
62
+ * (`packageScripts`); if such a script calls turbo and turbo.json doesn't pass the queue variables to tasks, it warns
63
+ * with a ready-made line (`turboEnv`).
62
64
  *
63
65
  * A copy on the machine without `--link` and a project's package version follow Elves releases — tags vYYYY.MM.DD — not
64
66
  * main: the bunx package from main is only a launcher — it finds the latest release and restarts from it by SHA
@@ -220,8 +222,8 @@ fixing CI, merge on green checks (auto in .agents/elves.json, default — no); w
220
222
  merge — Bash(gh pr merge *) in ~/.claude/settings.json: the owner adds it, install doesn't change the machine's
221
223
  settings. --lang <code> — the project language the agent works in: dialog, tasks, prompts, documents (language in
222
224
  .agents/elves.json, a BCP 47 code: ru, en, pt-BR); in a terminal — a question, en by default, without the flag and a
223
- terminal — the previous one. In an Elves clone writes only auto and language, and its cloud hook — with zsh, the flow
224
- from the clone itself.
225
+ terminal — the previous one. In an Elves clone writes only the manifest — auto and language, env and batch carried
226
+ over as in a project — and its cloud hook — with zsh, the flow from the clone itself.
225
227
  -g — onto the machine: the canon in ~/.agents/elves with Claude Code's rules ~/.claude/rules/elves*.md and the global
226
228
  rule files of Codex, Gemini CLI, Copilot, OpenCode and Amp linking to it (someone else's file stays), the skills
227
229
  (~/.agents/skills, ~/.claude/skills), the SessionStart hook and the elves command in the terminal — in ~/.local/bin or
@@ -239,8 +241,9 @@ Examples:
239
241
  elves check --hook
240
242
 
241
243
  Whether the install is behind, changes nothing: 0 — up to date, 1 — behind, the project's git ignores flow files, its
242
- compose file breaks task isolation or standards and architecture have no test:spec script, 2 — the check is
243
- unavailable; the machine's copy and a project's package version are compared with the latest release (from the npm
244
+ compose file breaks task isolation, standards and architecture have no test:spec script or the CI of a project (an
245
+ Elves clone too) with "batch": true runs a workflow on a PR into a batch branch — on.pull_request lets a batch branch in,
246
+ its gate is the checks before a commit and pr premerge (ci.md of the spec skill, «Batches»), 2 — the check is unavailable; the machine's copy and a project's package version are compared with the latest release (from the npm
244
247
  package — by restarting from the latest release's package), a --link clone — with origin/main. For a project names the
245
248
  mode (auto) and the language, holds the compose file to the task isolation rules (❌ per violation, ○ — not checked: no
246
249
  docker compose; the task-env skill), names a test that runs standards and architecture again next to test:spec (○, the
@@ -738,19 +741,16 @@ interface Manifest {
738
741
  auto?: boolean;
739
742
  language?: string;
740
743
  env?: unknown;
744
+ batch?: unknown;
741
745
  }
742
746
 
743
747
  /**
744
748
  * The install's manifest: `.agents/elves.json`, until `update` moves it — the manifest of an earlier release under the
745
- * flow's former name (FORMER.manifest); missing or broken — empty.
749
+ * flow's former name (FORMER.manifest); missing or broken — empty. Read by `manifestOf` of the github skill's locale
750
+ * module — the one reader of the manifest (tests/standards/project-manifest).
746
751
  */
747
752
  function readManifest(root: string): Manifest {
748
- const file = [MANIFEST, FORMER.manifest].map((f) => path.join(root, f)).find((f) => existsSync(f));
749
- try {
750
- return file ? JSON.parse(readFileSync(file, "utf8")) : {};
751
- } catch {
752
- return {};
753
- }
753
+ return locale.manifestOf(root).fields as Manifest;
754
754
  }
755
755
 
756
756
  /**
@@ -761,9 +761,9 @@ function readManifest(root: string): Manifest {
761
761
  * doesn't compare the SHA and the tag: the install is up to date if what it installs matches (a release that changes
762
762
  * only the installer doesn't touch projects). auto and language — the project settings "a task entirely without
763
763
  * asking" and the language the agent works in, always written; the machine doesn't have them (undefined). `env` —
764
- * the project's task stack settings (the task-env skill: HTTP services and their ports): the project writes it, the
765
- * install carries it over. A dry run doesn't compare them either: editing a field by hand doesn't make the install
766
- * "behind".
764
+ * the project's task stack settings (the task-env skill: HTTP services and their ports), `batch` — the dispatcher's
765
+ * batches (the github skill): the project writes them, the install carries them over. A dry run doesn't compare them
766
+ * either: editing a field by hand doesn't make the install "behind".
767
767
  * The order survives a run cut off midway (an agent's sandbox refuses writes to `.claude/skills`): skills Elves no
768
768
  * longer has go while the old manifest still lists them, and the new manifest is written before the copies — a repeat
769
769
  * run takes them for the flow's and finishes the install instead of leaving them as someone else's.
@@ -1374,15 +1374,15 @@ const modeLines = (root: string) => [` tasks: ${autoOf(root) ? "entirely by its
1374
1374
  const CLONE_NOTE = "an Elves clone: the flow isn't installed into it — the canon is at its root";
1375
1375
 
1376
1376
  /**
1377
- * An Elves clone: of the install it gets only the mode and the language, a manifest of two fields, and its cloud hook
1378
- * (`cloudHook`) — the clone carries it in its commit, the install writes the same; the task stack settings (`env`), if
1379
- * the clone has them, are carried over as in a project; the manifest under the former name goes.
1377
+ * An Elves clone: of the install it gets only the manifest — the mode, the language and the fields the project writes
1378
+ * (`env`, `batch`), carried over by the same rule as in a project (`manifestText`) — and its cloud hook (`cloudHook`) —
1379
+ * the clone carries it in its commit, the install writes the same; the manifest under the former name goes.
1380
1380
  */
1381
1381
  function installClone(root: string, auto: boolean, language: string) {
1382
1382
  const file = path.join(root, MANIFEST);
1383
- const env = readManifest(root).env;
1383
+ const text = manifestText(root, auto, language);
1384
1384
  mkdirSync(path.dirname(file), { recursive: true });
1385
- writeFileSync(file, JSON.stringify({ auto, language, ...(env === undefined ? {} : { env }) }, null, 2) + "\n");
1385
+ writeFileSync(file, text);
1386
1386
  removeFormer(root, FORMER.manifest);
1387
1387
  note(`${MANIFEST} — auto: ${auto}, language: ${language}; ${CLONE_NOTE}`);
1388
1388
  projectHook(root, true);
@@ -1413,7 +1413,8 @@ const shownPackage = (spec: string | undefined) => (spec === undefined ? `withou
1413
1413
 
1414
1414
  /**
1415
1415
  * A project with a copy of the flow of an earlier release: the canon `.agents/elves/` (under the former name too) or a
1416
- * manifest of a copy — the installed skills, the SHA; a thin project's manifest has only the mode, the language, env.
1416
+ * manifest of a copy — the installed skills, the SHA; a thin project's manifest has only the mode, the language, env
1417
+ * and batch.
1417
1418
  */
1418
1419
  function isCopy(root: string) {
1419
1420
  const m = readManifest(root);
@@ -1457,21 +1458,35 @@ function removeCopy(root: string) {
1457
1458
  if (gone.length) note(`${gone.join(", ")} — removed: ${COPY_NOTE}`);
1458
1459
  }
1459
1460
 
1460
- /** The fields of a project's manifest; the rest are a copy's (`isCopy`). */
1461
- const PROJECT_FIELDS = ["auto", "language", "env"];
1461
+ /**
1462
+ * The fields of a project's manifest the project writes itself — the task stack settings (`env`, the task-env skill)
1463
+ * and the dispatcher's batches (`batch`, the github skill's `task queue`, epic #645): the install carries them over.
1464
+ */
1465
+ const CARRIED_FIELDS = ["env", "batch"] as const;
1466
+
1467
+ /** The fields of a project's manifest: the install's auto and language and the project's own; the rest are a copy's (`isCopy`). */
1468
+ const PROJECT_FIELDS: string[] = ["auto", "language", ...CARRIED_FIELDS];
1469
+
1470
+ /**
1471
+ * The manifest the install writes — of a thin project and an Elves clone alike (#672): auto and language, the
1472
+ * CARRIED_FIELDS of the current manifest as they are; the fields of a copy go.
1473
+ */
1474
+ function manifestText(root: string, auto: boolean, language: string) {
1475
+ const m = readManifest(root);
1476
+ const carried = CARRIED_FIELDS.filter((k) => m[k] !== undefined).map((k) => [k, m[k]]);
1477
+ return JSON.stringify({ auto, language, ...Object.fromEntries(carried) }, null, 2) + "\n";
1478
+ }
1462
1479
 
1463
1480
  /**
1464
- * The project's manifest: the mode, the language and the task stack settings (`env`, the task-env skill — the project
1465
- * writes it, the install carries it over); the fields of a copy go, the manifest under the former name moves into it.
1466
- * A dry run doesn't compare the values: auto and language edited by hand don't make the install behind.
1481
+ * The project's manifest (`manifestText`); the manifest under the former name moves into it. A dry run doesn't compare
1482
+ * the values: auto and language edited by hand don't make the install behind.
1467
1483
  */
1468
1484
  function projectManifest(root: string, auto: boolean, language: string) {
1469
1485
  const file = path.join(root, MANIFEST);
1470
- const m = readManifest(root);
1471
1486
  if (dry) {
1472
- if (!existsSync(file) || Object.keys(m).some((k) => !PROJECT_FIELDS.includes(k))) change(existsSync(file) ? "~" : "+", file);
1487
+ if (!existsSync(file) || Object.keys(readManifest(root)).some((k) => !PROJECT_FIELDS.includes(k))) change(existsSync(file) ? "~" : "+", file);
1473
1488
  } else {
1474
- const text = JSON.stringify({ auto, language, ...(m.env === undefined ? {} : { env: m.env }) }, null, 2) + "\n";
1489
+ const text = manifestText(root, auto, language);
1475
1490
  if (!existsSync(file) || readFileSync(file, "utf8") !== text) {
1476
1491
  mkdirSync(path.dirname(file), { recursive: true });
1477
1492
  writeFileSync(file, text);
@@ -1683,10 +1698,10 @@ function rewriteCopyRefs(root: string) {
1683
1698
  }
1684
1699
 
1685
1700
  /**
1686
- * A mention of `.agents` that isn't the thin project's own: the manifest, the shim, `~/.agents` and `$HOME/.agents` of
1687
- * the machine aren't one.
1701
+ * A mention of a path inside `.agents/` (a glob too) that isn't the thin project's own: the directory itself, the
1702
+ * manifest, the shim, `~/.agents` and `$HOME/.agents` of the machine aren't one (#642).
1688
1703
  */
1689
- const COPY_MENTION = /(?<![\w~}-]|~\/|HOME\/|HOME}\/)\.agents(?![\w-])(?!\\?\/(?:elves\\?\.json|slot\\?\.sh)\b)/;
1704
+ const COPY_MENTION = /(?<![\w~}-]|~\/|HOME\/|HOME}\/)\.agents\\?\/(?!(?:elves\\?\.json|slot\\?\.sh)\b)[\w*.{]/;
1690
1705
 
1691
1706
  /**
1692
1707
  * What still names `.agents` once the copy is gone: exceptions `.agents/**` in linters, formatters and configs aren't
@@ -1865,7 +1880,7 @@ function inside(dir: string, p: string) {
1865
1880
  function ownCommand(file: string) {
1866
1881
  const st = lstat(file);
1867
1882
  if (st?.isSymbolicLink()) {
1868
- const to = readlinkSync(file);
1883
+ const to = path.resolve(path.dirname(file), readlinkSync(file));
1869
1884
  return /(?:^|\/)bin\/elves\.ts$/.test(to) && !to.split("/").includes("node_modules");
1870
1885
  }
1871
1886
  if (!st?.isFile() || st.size > 4096) return false;
@@ -1940,7 +1955,8 @@ function installCommand(src: string, home: string, linkMode: boolean) {
1940
1955
  note(`${shown(dst)} — ${COMMAND_NOTE}: ${linkMode ? `→ ${target}` : (/^exec (.*) "\$@"$/m.exec(text)?.[1] ?? "")}`);
1941
1956
  }
1942
1957
  const dirs = pathDirs();
1943
- const first = dirs.slice(0, dirs.indexOf(dir)).map((d) => path.join(d, COMMAND)).find(runnable);
1958
+ // `node_modules/.bin` in the process's PATH is the package manager's (bunx, npx, a script), not the terminal's (#642)
1959
+ const first = dirs.slice(0, dirs.indexOf(dir)).filter((d) => !/(?:^|\/)node_modules\/\.bin$/.test(d)).map((d) => path.join(d, COMMAND)).find(runnable);
1944
1960
  if (first) say(first, `comes first on PATH: elves runs it, not ${shown(dst)} — remove it or put ${shown(dir)} before ${shown(path.dirname(first))} in PATH`);
1945
1961
  }
1946
1962
 
@@ -2191,6 +2207,95 @@ function specScript(root: string): { lines: string[]; missing: boolean } {
2191
2207
  return { lines: [`${lead}; put in "scripts" — test without them, they don't run twice: ${spec}, ${filtered}`], missing: true };
2192
2208
  }
2193
2209
 
2210
+ /** Where a batch project's CI is described: the spec skill on the machine (`install -g`). */
2211
+ const BATCHES_DOC = "~/.agents/skills/spec/ci.md, «Batches»";
2212
+ /** A batch branch for a workflow's `on.pull_request` filter — the name `task queue` gives (the github skill). */
2213
+ const BATCH_SAMPLE = "batch/2026-01-01-1";
2214
+
2215
+ /**
2216
+ * Whether a workflow gets a run on a PR into `base`, by its `on:` (tests/standards/pr-workflow-run): `yes`, `no` or
2217
+ * `unknown` — not decidable without the PR's diff and event. `pull_request` as a string, in an array, a key with
2218
+ * filters or null; `pull_request_target` runs in the base, not on the PR's head — `no`. The branch filter goes first: a
2219
+ * base it filters out gets no run whatever the diff. Its patterns — GitHub's filter pattern cheat sheet: in order, `!`
2220
+ * at the start drops what the earlier ones took, a later pattern takes it back; `*` — anything but `/`, `**` — anything,
2221
+ * `?`, `+` and `[…]` — as in a regex; a pattern that isn't one takes nothing. Then a paths filter and `types` without
2222
+ * `synchronize` — `unknown`. What follows from the answer is the reader's: `batchCi` — a `❌` line on `yes` and on
2223
+ * `unknown` into a batch branch (a PR into it starts no workflow). A copy — PR_RUN in skills/ci-wait/scripts/wait-ci.sh
2224
+ * (`ci-wait pr` waits on `yes`): ci-wait and the installer don't import each other (tests/standards/skill-imports),
2225
+ * both answer by one table.
2226
+ */
2227
+ export function prRun(on: unknown, base: string): "yes" | "no" | "unknown" {
2228
+ const list = (v: unknown) => [v].flat();
2229
+ const takes = (patterns: unknown[]) => {
2230
+ let taken = false;
2231
+ for (const p of patterns) {
2232
+ if (typeof p !== "string") continue;
2233
+ const negated = p.startsWith("!");
2234
+ let re: RegExp;
2235
+ try {
2236
+ re = new RegExp(`^${(negated ? p.slice(1) : p).replace(/[.^$(){}|\\]/g, "\\$&").replace(/\*\*?/g, (m) => (m === "**" ? ".*" : "[^/]*"))}$`);
2237
+ } catch {
2238
+ continue;
2239
+ }
2240
+ if (re.test(base)) taken = !negated;
2241
+ }
2242
+ return taken;
2243
+ };
2244
+ let filter: unknown;
2245
+ if (typeof on === "string" || Array.isArray(on)) {
2246
+ if (!list(on).includes("pull_request")) return "no";
2247
+ filter = {};
2248
+ } else if (on && typeof on === "object" && Object.hasOwn(on, "pull_request")) {
2249
+ filter = (on as Record<string, unknown>).pull_request ?? {};
2250
+ } else return "no";
2251
+ if (!filter || typeof filter !== "object" || Array.isArray(filter)) return "unknown";
2252
+ const f = filter as Record<string, unknown>;
2253
+ if (Object.hasOwn(f, "branches") ? !takes(list(f.branches)) : Object.hasOwn(f, "branches-ignore") && takes(list(f["branches-ignore"]))) return "no";
2254
+ if (Object.hasOwn(f, "paths") || Object.hasOwn(f, "paths-ignore")) return "unknown";
2255
+ if (Object.hasOwn(f, "types") && !list(f.types).includes("synchronize")) return "unknown";
2256
+ return "yes";
2257
+ }
2258
+
2259
+ /** A workflow's triggers (`Bun.YAML`); not YAML — null: GitHub doesn't run it. */
2260
+ function workflowOf(text: string): { on?: unknown } | null {
2261
+ try {
2262
+ const wf: unknown = Bun.YAML.parse(text);
2263
+ return wf && typeof wf === "object" ? wf : null;
2264
+ } catch {
2265
+ return null;
2266
+ }
2267
+ }
2268
+
2269
+ /**
2270
+ * A batch project's CI (`"batch": true` in the manifest, epic #645; `batchProject` of the locale module — the same
2271
+ * answer as `task queue`'s), a thin project's and an Elves clone's alike (#672): a PR into the batch branch starts no
2272
+ * workflow (#676) — its gate is the checks the executor
2273
+ * runs before a commit and `pr premerge`, the full CI is the release PR's and `main`'s (`BATCHES_DOC`); a run on every
2274
+ * task PR repeats what the executor ran and holds its slot. A mismatch update doesn't fix, as the compose file and
2275
+ * `test:spec`: a `❌` line per workflow of `.github/workflows/*.y(a)ml` that by `prRun` runs on a PR into a batch
2276
+ * branch — `yes`, or `unknown` (a paths filter, `types` without `synchronize`): it may run, the line says so. `no` —
2277
+ * no line: a branch filter that leaves the batch branch out, push only, `pull_request_target`. A workflow that doesn't
2278
+ * parse runs nothing on GitHub either. Not a batch project — CI isn't read.
2279
+ */
2280
+ function batchCi(root: string): { lines: string[]; missing: boolean } {
2281
+ if (!locale.batchProject(root)) return { lines: [], missing: false };
2282
+ const dir = ".github/workflows";
2283
+ let files: string[] = [];
2284
+ try {
2285
+ files = readdirSync(path.join(root, dir)).filter((f) => /\.ya?ml$/.test(f)).sort();
2286
+ } catch {
2287
+ // no workflows — nothing runs on a PR into a batch branch
2288
+ }
2289
+ const lines: string[] = [];
2290
+ for (const f of files) {
2291
+ const run = prRun(workflowOf(readFileSync(path.join(root, dir, f), "utf8"))?.on, BATCH_SAMPLE);
2292
+ if (run === "no") continue;
2293
+ const how = run === "yes" ? "runs" : "may run (by the PR's diff or event)";
2294
+ lines.push(` ❌ ${dir}/${f}: on.pull_request ${how} on a PR into a batch branch — its gate is the checks before a commit and pr premerge, the full CI is the release's; add branches-ignore: ['batch/**'] (${BATCHES_DOC})`);
2295
+ }
2296
+ return { lines, missing: lines.length > 0 };
2297
+ }
2298
+
2194
2299
  /** The check failed: what and why. */
2195
2300
  const unavailable = (global: boolean, e: unknown): Result => ({ code: 2, text: `Elves ${global ? "on the machine" : "in the project"}: check unavailable — ${reason(e)}` });
2196
2301
 
@@ -2290,8 +2395,12 @@ function cloudLines(root: string): string[] {
2290
2395
  function check(global: boolean): Result {
2291
2396
  const { home, root, installed } = target(global);
2292
2397
  const where = global ? "on the machine" : "in the project";
2293
- // an Elves clone has nothing to compare against, but the session mode is needed in it — as in a project
2294
- if (!installed && !global && isElvesClone(root)) return { code: 0, text: [`Elves ${where}: not installed — an Elves clone, the canon is at its root`, ...cloudLines(root), ...modeLines(root)].join("\n") };
2398
+ // an Elves clone has nothing to compare against, but the session mode is needed in it — as in a project, and so is
2399
+ // the CI of its batches (#672)
2400
+ if (!installed && !global && isElvesClone(root)) {
2401
+ const batch = batchCi(root);
2402
+ return { code: batch.missing ? 1 : 0, text: [`Elves ${where}: not installed — an Elves clone, the canon is at its root`, ...batch.lines, ...cloudLines(root), ...modeLines(root)].join("\n") };
2403
+ }
2295
2404
  if (!installed) return { code: 0, text: `Elves ${where}: not installed`, absent: true };
2296
2405
  const clone = global ? linkedClone(home) : null;
2297
2406
  if (clone) return checkLink(home, clone);
@@ -2306,10 +2415,12 @@ function check(global: boolean): Result {
2306
2415
  const compose = global ? { lines: [], violations: false } : composeCheck(root);
2307
2416
  // standards without test:spec — a mismatch update doesn't add either
2308
2417
  const spec = global ? { lines: [], missing: false } : specScript(root);
2309
- const project = [...warnings, ...compose.lines, ...spec.lines, ...(global ? [] : cloudLines(root)), ...mode];
2418
+ // a batch project's CI that runs on a PR into a batch branch — a mismatch update doesn't fix either
2419
+ const batch = global ? { lines: [], missing: false } : batchCi(root);
2420
+ const project = [...warnings, ...compose.lines, ...spec.lines, ...batch.lines, ...(global ? [] : cloudLines(root)), ...mode];
2310
2421
  const stale = changes.filter((c) => c.op !== "❌" && c.op !== "!");
2311
2422
  const ignored = changes.filter((c) => c.op === "❌");
2312
- if (!stale.length && !ignored.length) return { code: compose.violations || spec.missing ? 1 : 0, compose: compose.violations, spec: spec.missing, text: [`Elves ${where}: up to date (${fresh})`, ...project].join("\n") };
2423
+ if (!stale.length && !ignored.length) return { code: compose.violations || spec.missing || batch.missing ? 1 : 0, compose: compose.violations, spec: spec.missing, text: [`Elves ${where}: up to date (${fresh})`, ...project].join("\n") };
2313
2424
  const m = readManifest(root);
2314
2425
  const fixable = ignored.some((c) => !c.manual);
2315
2426
  const lost = "ignores flow files — they are missing from the commit, other checkouts and the cloud session:";
@@ -3034,7 +3145,12 @@ function takeTag(args: string[]): { tag: string | undefined; rest: string[] } {
3034
3145
  return i < 0 ? { tag: undefined, rest: args } : { tag: args[i], rest: args.filter((_, j) => j !== i) };
3035
3146
  }
3036
3147
 
3037
- async function main(argv: string[]): Promise<number> {
3148
+ /**
3149
+ * The command line. It runs only as the entry point (`import.meta.main`, as the skills' scripts): a test imports the
3150
+ * installer for `prRun` (tests/standards/pr-workflow-run); the installer under its path before the rename calls it
3151
+ * itself.
3152
+ */
3153
+ export async function main(argv: string[]): Promise<number> {
3038
3154
  const [cmd = "", ...args] = argv;
3039
3155
  // a skill's script — at this package's version, the project's: no launcher, the help is the script's
3040
3156
  if (Object.hasOwn(SCRIPTS, cmd)) return runScript(cmd, args);
@@ -3118,4 +3234,4 @@ async function main(argv: string[]): Promise<number> {
3118
3234
  }
3119
3235
  }
3120
3236
 
3121
- process.exitCode = await main(process.argv.slice(2));
3237
+ if (import.meta.main) process.exitCode = await main(process.argv.slice(2));