@miroshnik/elves 2026.1018.0 → 2026.1018.2

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
@@ -2,9 +2,8 @@
2
2
 
3
3
  ## This file and skills
4
4
 
5
- The Elves canon for any agent (Claude Code, Codex, Gemini CLI, Cursor,
6
- Copilot, OpenCode, Amp). It lives in **miroshnik/elves** with the skills
7
- (`skills/*`) and references (`docs/`, next to this file), installed
5
+ The Elves canon for any agent. It lives in **miroshnik/elves** with the
6
+ skills (`skills/*`) and references (`docs/`, next to this file), installed
8
7
  onto the machine — README, "Installation". A rule or skill change from
9
8
  another project's session is a task in Elves (project #6) without that
10
9
  project's names, for a separate session; I don't edit an Elves clone from
@@ -89,8 +88,7 @@ done. The session can be closed.` · `All done, but there are questions: …` ·
89
88
 
90
89
  - Tasks live in the repository's **GitHub Issues**, not in local lists, TODO
91
90
  files or the agent's task list; no repository — ask where. The `github`
92
- skill runs them (`task new`, `show`, `answer`, `status`, `close`, `drop`,
93
- `pr labels`), not `gh issue view`/`edit`; how tasks, the project and
91
+ skill runs them, not `gh issue view`/`edit`; how tasks, the project and
94
92
  milestones are organized — its `reference.md`.
95
93
  - **Types:** Task (one PR), Bug, Epic (a personal account — the `epic`
96
94
  label). **Decision labels** — the decision's name, kind by color: a task
@@ -121,11 +119,13 @@ done. The session can be closed.` · `All done, but there are questions: …` ·
121
119
  question «run the dispatcher on epic #M?», without In progress.
122
120
  - **A dispatcher** — "do all tasks of epic #M", "fix all High bugs", a "yes"
123
121
  — any session, its task stays; one started for it — in the project folder,
124
- not a worktree: executors start from `main`. `github task queue` — ready
125
- ones with executors' prompts; an executor — a background subagent in its
126
- worktree or a cloud session: at most 3 subagents and 5 cloud sessions at
127
- once, the owner's number replaces either; its question — to the owner,
128
- the answer into the issue and back; failed — a task comment, no restart.
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.
129
129
  - **The session is named `#<number> <task title>`** (without a task — by
130
130
  meaning, no number) as soon as the task is clear (no tool — ask the user):
131
131
  `est` links the session to the task by it.
package/README.md CHANGED
@@ -216,7 +216,7 @@ The machine's copy and a project's package version follow the flow **by releases
216
216
  <details>
217
217
  <summary><b>The package in a project — the harness and the scripts at the project's version</b></summary>
218
218
 
219
- With `@miroshnik/elves` in a project's `devDependencies`, `bun install` brings the flow at the project's version — for CI, colleagues and a cloud session too (npm works there, `github:` gives 403). Tests import `@miroshnik/elves/spec/harness`, `@miroshnik/elves/spec/architecture`, `@miroshnik/elves/spec/eslint-config` and `@miroshnik/elves/github`: Bun and the type checker take the TypeScript source, any runner on Node (Jest, Playwright, `node:test`, the project's `eslint.config.js`) — the build. The skills' scripts run from `node_modules/.bin` with the same arguments as the scripts themselves, under `bun --no-env-file` — in `package.json` and CI alike: `bun` reads the TypeScript bin `elves` itself, past its shebang, and without the flag it would get the project's env files (`.env.local` with production keys) and pass their keys on to `gh`, `git` and the checked command. On Yarn 2+ — `bunx @miroshnik/elves@"$(jq -r '.devDependencies["@miroshnik/elves"]' package.json)" <script>`, the form `install` writes there: Yarn 2+ runs a `.ts` bin by Node with any `nodeLinker`, and PnP has no `node_modules`.
219
+ With `@miroshnik/elves` in a project's `devDependencies`, `bun install` brings the flow at the project's version — for CI, colleagues and a cloud session too (npm works there, `github:` gives 403). Tests import `@miroshnik/elves/spec/harness`, `@miroshnik/elves/spec/architecture`, `@miroshnik/elves/spec/eslint-config` and `@miroshnik/elves/github`: Bun and the type checker take the TypeScript source, any runner on Node (Jest, Playwright, `node:test`, the project's `eslint.config.js`) — the build; the knip reporter `@miroshnik/elves/spec/knip-hints` is CommonJS as is, its `file` — the path for `knip --reporter`. The skills' scripts run from `node_modules/.bin` with the same arguments as the scripts themselves, under `bun --no-env-file` — in `package.json` and CI alike: `bun` reads the TypeScript bin `elves` itself, past its shebang, and without the flag it would get the project's env files (`.env.local` with production keys) and pass their keys on to `gh`, `git` and the checked command. On Yarn 2+ — `bunx @miroshnik/elves@"$(jq -r '.devDependencies["@miroshnik/elves"]' package.json)" <script>`, the form `install` writes there: Yarn 2+ runs a `.ts` bin by Node with any `nodeLinker`, and PnP has no `node_modules`.
220
220
 
221
221
  ```bash
222
222
  bun --no-env-file node_modules/.bin/elves spec-doc …
@@ -227,9 +227,17 @@ bun --no-env-file node_modules/.bin/elves spec-shard …
227
227
  bun --no-env-file node_modules/.bin/elves spec-claims …
228
228
  bun --no-env-file node_modules/.bin/elves spec-break …
229
229
  bun --no-env-file node_modules/.bin/elves spec-exceptions …
230
+ bun --no-env-file node_modules/.bin/elves task-env up …
231
+ bun --no-env-file node_modules/.bin/elves task-env e2e …
232
+ bun --no-env-file node_modules/.bin/elves task-env down …
233
+ bun --no-env-file node_modules/.bin/elves task-env prune
234
+ bun --no-env-file node_modules/.bin/elves task-env check …
235
+ bun --no-env-file node_modules/.bin/elves task-env proxy up
236
+ bun --no-env-file node_modules/.bin/elves task-env proxy down
237
+ bun --no-env-file node_modules/.bin/elves task-env proxy status
230
238
  ```
231
239
 
232
- The machine queue — `elves slot --for <PID>` of the shim `.agents/slot.sh`: it only prints the queue variables, the command runs in the script's own shell. A GitHub Actions step doesn't see `node_modules/.bin`, and `bunx elves` without the package in `node_modules` would fetch someone else's `elves` from npm; a job that doesn't install the project's dependencies — `bunx @miroshnik/elves@<version> <script>` (the `spec` skill's `ci.md`).
240
+ The project's own scripts and tests (the stack before the dev server, the e2e wrapper) bring the task's stack up with `elves task-env up` — the `task-env` skill at the project's version. The machine queue — `elves slot --for <PID>` of the shim `.agents/slot.sh`: it only prints the queue variables, the command runs in the script's own shell. A GitHub Actions step doesn't see `node_modules/.bin`, and `bunx elves` without the package in `node_modules` would fetch someone else's `elves` from npm; a job that doesn't install the project's dependencies — `bunx @miroshnik/elves@<version> <script>` (the `spec` skill's `ci.md`).
233
241
 
234
242
  </details>
235
243
 
package/bin/elves.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * bun <Elves checkout>/bin/elves.ts pack <tag> — CI on the release tag: the npm package @miroshnik/elves
16
16
  * bun --no-env-file node_modules/.bin/elves spec-doc … — a project's package: the skills' scripts at its version
17
17
  * elves slot --for <PID> — the machine queue's shim .agents/slot.sh in a project
18
+ * bun --no-env-file node_modules/.bin/elves task-env up — a project's scripts and tests: the task's stack, task-env
18
19
  *
19
20
  * The shebang goes through `sh`: the second line — `exec bun --no-env-file` for sh, a string and a comment for TypeScript
20
21
  * — starts Bun without the project's env files however the bin is called (`elves`, `node_modules/.bin/elves`, a
@@ -32,8 +33,9 @@
32
33
  * settings, #610); updating the project is bumping the package version — a few lines in the branch of the current task.
33
34
  * A project with a copy of an earlier release loads the canon twice until it moves — the hook names it (`twiceLines`);
34
35
  * `install` and `update` move it to a thin one: the copy, its symlinks and `.gitignore` exceptions go (`removeCopy`),
35
- * the imports and CI paths into the copy lead to the package (`rewriteCopyRefs`), the rest that names `.agents` is
36
- * listed (`copyLeftovers`).
36
+ * the imports, scripts and CI paths into the copy lead to the package (`rewriteCopyRefs`), the rest that names `.agents`
37
+ * is listed (`copyLeftovers`), and `check` names a line into a file of the copy that update doesn't rewrite with `!`
38
+ * (`copyRefs`, #620).
37
39
  * `.agents/elves.json` — on the machine the Elves SHA it was installed from, the list of installed skills (by it a
38
40
  * reinstall removes skills Elves no longer has and leaves others' alone), for a machine with `--link` — the clone path;
39
41
  * in a project — only `auto`: whether the agent runs a task entirely by itself — answers to questions by
@@ -208,8 +210,9 @@ on the machine and the cloud session's SessionStart hook in .claude/settings.jso
208
210
  it exits at once; the package.json checks (lint, typecheck, test, test:*) go through the shim, the skills' scripts —
209
211
  ${PACKAGE_RUN} <script>, without the project's env files; on Yarn 2+, which runs the .ts bin by Node, both go through
210
212
  the package of the project's version — ${PACKAGE_BUNX} <script>. A project with a copy of the flow
211
- of an earlier release moves to a thin one: the copy, its symlinks and .gitignore exceptions go, imports and CI paths
212
- into the copy lead to the package, what else names .agents is listed.
213
+ of an earlier release moves to a thin one: the copy, its symlinks and .gitignore exceptions go, imports into the copy
214
+ (the knip reporter — ${PACKAGE}/spec/knip-hints) and its scripts run by bun or node in package.json, shell scripts,
215
+ command strings and CI (task-env too) lead to the package, what else names .agents is listed.
213
216
  In a terminal asks whether the agent does tasks entirely by itself — answers by recommendation, commit, push,
214
217
  fixing CI, merge on green checks (auto in .agents/elves.json, default — no); without a terminal — the --auto or
215
218
  --no-auto flag, without the flag the previous answer stays; with auto prints the Claude Code permission rule for the
@@ -238,7 +241,8 @@ package — by restarting from the latest release's package), a --link clone —
238
241
  mode (auto) and the language, holds the compose file to the task isolation rules (❌ per violation, ○ — not checked: no
239
242
  docker compose; the task-env skill), names a test that runs standards and architecture again next to test:spec (○, the
240
243
  ready test without them) and warns (!) if a script in slot calls turbo and turbo.json doesn't pass the queue variables
241
- to tasks (the exit code stays the same); a thin project behind — update in this session's task (the package version in
244
+ to tasks or a line leads into a file of the flow copy that update doesn't rewrite — the file, the line number and its
245
+ text (the exit code stays the same); a thin project behind — update in this session's task (the package version in
242
246
  package.json and the lockfile), a project with a copy of the flow — the move to a thin project in a task of its own: the
243
247
  task new command of the github skill, one task per project. For the machine names the task-env proxy of *.localhost
244
248
  addresses — running, not running or running
@@ -258,12 +262,14 @@ Examples:
258
262
  Brings it up to date: a --link clone — git pull --ff-only, the machine's copy — install of the latest release: the
259
263
  canon, the skills and the hook; a project — install of the latest release: ${PACKAGE} in package.json and the
260
264
  lockfile, a few lines in the branch of the current task; a project with a copy of the flow moves to a thin one (install
261
- --help); from the npm package — by restarting from the latest release's package. The project's mode and language are
262
- kept; an install of a previous version without a language gets ru: before the field the flow worked in Russian. In a
263
- project install and update un-ignore flow files from .gitignore patterns with exceptions at its end and wrap the
264
- package.json checks (lint, typecheck, test, test:*) in the machine queue slot; prints what changed: every release after
265
- the installed one, newest first, with the list of changes of its GitHub Release (a --link clone — the flow commits it
266
- pulled), and the git add of what changed. An install from before the rename to Elves — the ai-dev names
265
+ --help), by hand after it — a check of the project that knows the queue wrapper by the copy's path (. .agents/slot.sh
266
+ now), AI_DEV_SLOT* in the project's code → ELVES_SLOT*, formatting the shortened imports, git add -A before the first
267
+ run of the checks, the ! lines of check; from the npm package — by restarting from the latest release's package. The
268
+ project's mode and language are kept; an install of a previous version without a language gets ru: before the field
269
+ the flow worked in Russian. In a project install and update un-ignore flow files from .gitignore patterns with
270
+ exceptions at its end and wrap the package.json checks (lint, typecheck, test, test:*) in the machine queue slot;
271
+ prints what changed: every release after the installed one, newest first, with the list of changes of its GitHub
272
+ Release (a --link clone — the flow commits it pulled), and the git add of what changed. An install from before the rename to Elves — the ai-dev names
267
273
  (.agents/ai-dev.json, .agents/ai-dev/, .claude/rules/ai-dev*.md, the blocks in AGENTS.md and .gitignore,
268
274
  ~/.config/ai-dev) move to the new ones with the same contents.
269
275
 
@@ -296,22 +302,41 @@ Examples:
296
302
  * arguments, in this process; the project's paths (`.agents/elves.json`, `tests/`, the reports) come from the current
297
303
  * directory and its git root, as with `bun <copy>/scripts/<script>.ts`. No release launcher (`toRelease`): the version
298
304
  * is the project's. Help — the script's own `HELP` under the elves name; `--help` counts where the script reads it: a
299
- * spec script — before `--`, slot — only as the first argument (the rest is its command). A script module runs its
300
- * `main` itself only as the entry point (`import.meta.main`).
305
+ * spec script and task-env — before `--`, slot — only as the first argument (the rest is its command). A script module
306
+ * runs its `main` itself only as the entry point (`import.meta.main`). task-env — so that a project's scripts and tests
307
+ * (bringing the stack up before the dev server, the e2e wrapper, the compose isolation test) call it at the project's
308
+ * version without a path into a skill directory, which breaks when the package isn't hoisted to the root
309
+ * `node_modules` (#620).
301
310
  */
302
311
  const SCRIPTS: Record<string, { file: string; helpFirst?: boolean }> = {
303
312
  ...Object.fromEntries(["spec-doc", "spec-diff", "spec-publish", "spec-run", "spec-shard", "spec-claims", "spec-break", "spec-exceptions"].map((name) => [name, { file: `skills/spec/scripts/${name}.ts` }])),
304
313
  slot: { file: "skills/slot/scripts/slot.ts", helpFirst: true },
314
+ "task-env": { file: "skills/task-env/scripts/task-env.ts" },
305
315
  };
306
316
 
307
- /** A script of SCRIPTS: its help and its entry point. */
317
+ /**
318
+ * A script of SCRIPTS: its help and its entry point. A script with commands (task-env) — its help is a table by
319
+ * command and an overview of them all.
320
+ */
308
321
  interface Script {
309
- HELP: string;
322
+ HELP: string | Record<string, string>;
323
+ OVERVIEW?: string;
310
324
  main: (argv: string[]) => number | Promise<number>;
311
325
  }
312
326
 
313
327
  const loadScript = (cmd: string): Promise<Script> => import(path.join(SRC, SCRIPTS[cmd]!.file));
314
328
 
329
+ /** A script's help the arguments ask for: the text; with commands — of the command they start with, otherwise the overview. */
330
+ function helpText(s: Script, args: string[]): string {
331
+ if (typeof s.HELP === "string") return s.HELP;
332
+ const table = s.HELP;
333
+ const command = Object.keys(table).find((c) => `${args.join(" ")} `.startsWith(`${c} `));
334
+ return command ? table[command]! : (s.OVERVIEW ?? "");
335
+ }
336
+
337
+ /** The synopses of a script for the `elves --help` overview: the first paragraph of its help, of each command's for a table. */
338
+ const synopses = (s: Script) => (typeof s.HELP === "string" ? [s.HELP] : Object.values(s.HELP)).map((h) => h.split("\n\n")[0]!);
339
+
315
340
  /**
316
341
  * The package's script in the current project — the form of its package.json: on Yarn 2+ through bunx (`packageRun`).
317
342
  * The root — by `.git` up from the current directory, without git: help runs no external commands
@@ -325,25 +350,26 @@ const projectRun = () => packageRun(packageManager(locale.projectRoot(process.cw
325
350
  * the form of package.json and CI and the project's version, while bare `elves` in the agent's shell is the machine's
326
351
  * global package, if it's on PATH at all (tests/standards/no-env-file).
327
352
  */
328
- const scriptHelp = async (cmd: string, run = projectRun()) => (await loadScript(cmd)).HELP.replace(new RegExp(`^( *)${cmd}(?= |$)`, "gm"), `$1${run} ${cmd}`).trimEnd();
353
+ const underElves = (cmd: string, text: string, run = projectRun()) => text.replace(new RegExp(`^( *)${cmd}(?= |$)`, "gm"), `$1${run} ${cmd}`).trimEnd();
329
354
 
330
355
  /** `elves <script> …`: the script's help or its `main` with the arguments; the exit code is the script's. */
331
356
  async function runScript(cmd: string, args: string[]): Promise<number> {
332
357
  const options = SCRIPTS[cmd]!.helpFirst ? args.slice(0, 1) : args.includes("--") ? args.slice(0, args.indexOf("--")) : args;
333
- if (options.some((a) => a === "--help" || a === "-h")) return note(await scriptHelp(cmd)), 0;
334
- return (await loadScript(cmd)).main(args);
358
+ const script = await loadScript(cmd);
359
+ if (options.some((a) => a === "--help" || a === "-h")) return note(underElves(cmd, helpText(script, options))), 0;
360
+ return script.main(args);
335
361
  }
336
362
 
337
363
  /** `elves --help`: the synopsis of each command, the scripts' too, and how to run it. */
338
364
  async function overview() {
339
365
  const synopsis = (h: string) => h.split("\n\n")[0]!.replace(/^/gm, " ");
340
366
  const run = projectRun();
341
- const scripts = await Promise.all(Object.keys(SCRIPTS).map((cmd) => scriptHelp(cmd, run)));
367
+ const scripts = await Promise.all(Object.keys(SCRIPTS).map(async (cmd) => synopses(await loadScript(cmd)).map((h) => underElves(cmd, h, run))));
342
368
  return [
343
369
  "elves — the rules and skills of the Elves flow: install, check, update, release, pack; skill scripts at a project's version",
344
370
  "",
345
371
  ...Object.values(HELP).map(synopsis),
346
- ...scripts.map(synopsis),
372
+ ...scripts.flat().map(synopsis),
347
373
  "",
348
374
  "elves <command> --help — the command's details and examples",
349
375
  `Run: ${RUN} <command>; in a project with ${PACKAGE} in devDependencies — ${run} <command>`,
@@ -842,22 +868,28 @@ function slotShim(root: string) {
842
868
  }
843
869
 
844
870
  /**
845
- * A skill script the flow copy runs — `bun [--no-env-file] .agents/skills/<skill>/scripts/<script>.ts` of SCRIPTS — and
846
- * the knip reporter of the copy (`knip --reporter …/knip-hints.cjs`, the harness's `report`): the package has them,
847
- * the copy is gone. The script and its arguments are the same (SCRIPTS).
871
+ * A skill script the flow copy runs — `bun|node [--<flag>]… .agents/skills/<skill>/scripts/<script>.ts` of SCRIPTS, the
872
+ * forms projects used (`bun --no-env-file`, `node --experimental-strip-types`, `./` before the path, #620) — and the knip
873
+ * reporter of the copy (`knip --reporter …/knip-hints.cjs`, the harness's `report`): the package has them, the copy is
874
+ * gone. The script and its arguments are the same (SCRIPTS).
848
875
  */
849
- const COPY_SCRIPT = /\bbun(?: --no-env-file)? (?:\.\/)?\.agents\/skills\/(?:spec|slot)\/scripts\/([\w-]+)\.ts\b/g;
876
+ const COPY_SCRIPT = /\b(?:bun|node)(?: --[\w-]+)* (?:\.\/)?\.agents\/skills\/([\w-]+)\/scripts\/([\w-]+)\.ts\b/g;
850
877
  const COPY_KNIP_HINTS = /(?:\.\/)?\.agents\/skills\/spec\/scripts\/knip-hints\.cjs\b/g;
851
- /** The knip reporter in the project's package: `exports` of package.json don't have it, the file does. */
878
+ /**
879
+ * The knip reporter in the project's package for a command (`knip --reporter <file>`); a module takes its path from the
880
+ * export — `file` of `@miroshnik/elves/spec/knip-hints` (`COPY_IMPORT`).
881
+ */
852
882
  const PACKAGE_KNIP_HINTS = `node_modules/${PACKAGE}/skills/spec/scripts/knip-hints.cjs`;
853
883
 
854
884
  /**
855
885
  * A command that ran the copy's scripts — through the package: `<run> <script>`, `PACKAGE_RUN` by default; on Yarn 2+
856
886
  * and in a CI job without the project's dependencies — `PACKAGE_BUNX` (`packageRun`, `ciFromCopy`). A script that isn't
857
- * the package's stays — `copyLeftovers` names it.
887
+ * the package's stays — `copyLeftovers` names it, `check` — `copyRefs`.
858
888
  */
859
889
  function fromCopy(text: string, run = PACKAGE_RUN) {
860
- return text.replace(COPY_SCRIPT, (all, name: string) => (Object.hasOwn(SCRIPTS, name) ? `${run} ${name}` : all)).replace(COPY_KNIP_HINTS, PACKAGE_KNIP_HINTS);
890
+ return text
891
+ .replace(COPY_SCRIPT, (all, skill: string, name: string) => (Object.hasOwn(SCRIPTS, name) && SCRIPTS[name]!.file === `skills/${skill}/scripts/${name}.ts` ? `${run} ${name}` : all))
892
+ .replace(COPY_KNIP_HINTS, PACKAGE_KNIP_HINTS);
861
893
  }
862
894
 
863
895
  /**
@@ -891,9 +923,7 @@ function packageScripts(root: string) {
891
923
  let hidden = false;
892
924
  for (const [name, cmd] of Object.entries(scripts)) {
893
925
  if (typeof cmd !== "string") continue;
894
- const inner = slotted(name) ? unslot(cmd) : cmd;
895
- const own = fromCopy(inner, run);
896
- const want = slotted(name) ? wrap(own) : own;
926
+ const { inner, own, want } = scriptAfter(name, cmd, yarn);
897
927
  if (cmd === want) continue;
898
928
  scripts[name] = want;
899
929
  if (own !== inner) copy.push(name);
@@ -918,6 +948,16 @@ function packageScripts(root: string) {
918
948
  if (copy.length) note(`package.json — the flow copy's scripts through the package (${run} <script>): ${copy.join(", ")}`);
919
949
  }
920
950
 
951
+ /**
952
+ * A package.json script as the install leaves it (`packageScripts`): `inner` — its own command without a slot wrapper,
953
+ * `own` — with the copy's scripts through the package, `want` — a check wrapped in the machine queue.
954
+ */
955
+ function scriptAfter(name: string, cmd: string, yarn: boolean) {
956
+ const inner = slotted(name) ? unslot(cmd) : cmd;
957
+ const own = fromCopy(inner, packageRun(yarn));
958
+ return { inner, own, want: slotted(name) ? slotWrap(yarn)(own) : own };
959
+ }
960
+
921
961
  /**
922
962
  * Queue variables a command in a slot needs: the runner's share of cores and "already in a slot" for a nested script
923
963
  * in slot.
@@ -1054,6 +1094,18 @@ function projectHook(root: string, clone: boolean) {
1054
1094
  function agentsBlock(root: string) {
1055
1095
  const file = path.join(root, "AGENTS.md");
1056
1096
  const text = existsSync(file) ? readFileSync(file, "utf8") : "";
1097
+ const out = agentsWith(text);
1098
+ if (out !== text) {
1099
+ const former = text.includes(FORMER.begin) ? { note: `the block under the flow's former name (${FORMER.begin.slice(5)}) — update replaces it` } : {};
1100
+ if (dry) return change(text ? "~" : "+", file, former);
1101
+ writeFileSync(file, out);
1102
+ touch("AGENTS.md");
1103
+ }
1104
+ note(`AGENTS.md — a block linking to ~/${CANON}/AGENTS.md on the machine`);
1105
+ }
1106
+
1107
+ /** The project's AGENTS.md text with the block in place: the block of an earlier install or under the former name replaced, a second one gone. */
1108
+ function agentsWith(text: string) {
1057
1109
  let out = text;
1058
1110
  let placed = false;
1059
1111
  for (const [begin, end] of [
@@ -1067,14 +1119,7 @@ function agentsBlock(root: string) {
1067
1119
  out = placed ? out.slice(0, b) + out.slice(e + end.length).replace(/^\n+/, "") : out.slice(0, b) + BLOCK + out.slice(e + end.length);
1068
1120
  placed = true;
1069
1121
  }
1070
- if (!placed) out = text ? `${BLOCK}\n\n${text}` : `${BLOCK}\n`;
1071
- if (out !== text) {
1072
- const former = text.includes(FORMER.begin) ? { note: `the block under the flow's former name (${FORMER.begin.slice(5)}) — update replaces it` } : {};
1073
- if (dry) return change(text ? "~" : "+", file, former);
1074
- writeFileSync(file, out);
1075
- touch("AGENTS.md");
1076
- }
1077
- note(`AGENTS.md — a block linking to ~/${CANON}/AGENTS.md on the machine`);
1122
+ return placed ? out : text ? `${BLOCK}\n\n${text}` : `${BLOCK}\n`;
1078
1123
  }
1079
1124
 
1080
1125
  const GI_BEGIN = "# elves:begin";
@@ -1499,11 +1544,31 @@ function gitGrep(root: string, args: string[]): string[] {
1499
1544
  const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1500
1545
  /** A workflow or an action of GitHub Actions: the copy's scripts go there by path. */
1501
1546
  const CI_FILE = /^\.github\/.*\.ya?ml$/;
1502
- /** An import of a copy's module the package exports (`exports` of package.json): the harness, the model, the ESLint fragment, github. */
1503
- const COPY_IMPORT = /(["'`])(?:\.{1,2}\/)*\.agents\/skills\/(spec\/scripts\/(?:harness|architecture|eslint-config)|github\/scripts\/github)(?:\.[cm]?[jt]s)?\1/g;
1547
+ /** A shell script of the project: the copy's scripts go there by path (#620) — bringing the stack up, the e2e wrapper. */
1548
+ const SHELL_FILE = /\.(?:sh|bash|zsh)$/;
1549
+ /**
1550
+ * An import of a copy's module the package exports (`exports` of package.json): the harness, the model, the ESLint
1551
+ * fragment, the knip reporter (its `file` — the path for `knip --reporter`, #620), github.
1552
+ */
1553
+ const COPY_IMPORT = /(["'`])(?:\.{1,2}\/)*\.agents\/skills\/(spec\/scripts\/(?:harness|architecture|eslint-config|knip-hints)|github\/scripts\/github)(?:\.[cm]?[jt]s)?\1/g;
1504
1554
  /** The package's export of a copy's module: spec/scripts/harness → @miroshnik/elves/spec/harness, github/scripts/github → @miroshnik/elves/github. */
1505
1555
  const packageImport = (mod: string) => `${PACKAGE}/${mod.startsWith("spec/") ? mod.replace("/scripts/", "/") : "github"}`;
1506
1556
 
1557
+ /**
1558
+ * A project's file with what leads into the copy rewritten, by the file's kind: a workflow — `ciFromCopy` (its jobs
1559
+ * whether dependencies are installed is unknown — `unknown`); a shell script — the copy's scripts through the package,
1560
+ * the form of package.json; a source file — imports of the copy's modules to the package's exports and the copy's
1561
+ * scripts in its command strings (on Yarn 2+ only the imports: the bunx form has quotes a string literal doesn't
1562
+ * take). Another kind — null: the install doesn't rewrite it.
1563
+ */
1564
+ function copyRewrite(rel: string, text: string, berry: boolean): { out: string; unknown: string[] } | null {
1565
+ if (CI_FILE.test(rel)) return ciFromCopy(text, berry);
1566
+ if (SHELL_FILE.test(rel)) return { out: fromCopy(text, packageRun(berry)), unknown: [] };
1567
+ if (!SOURCE_FILE.test(rel)) return null;
1568
+ const imports = text.replace(COPY_IMPORT, (_, q: string, mod: string) => `${q}${packageImport(mod)}${q}`);
1569
+ return { out: berry ? imports : fromCopy(imports), unknown: [] };
1570
+ }
1571
+
1507
1572
  /**
1508
1573
  * A step that installs the project's dependencies: `bun`, `npm`, `pnpm` or `yarn` with `install`, `ci`, `i`, a bare
1509
1574
  * `yarn`; a global install (`-g`, `--global`) brings a tool, not them.
@@ -1581,24 +1646,24 @@ function ciFromCopy(text: string, berry: boolean) {
1581
1646
  }
1582
1647
 
1583
1648
  /**
1584
- * Files of the project that lead into the copy (#558): imports of the copy's modules in tests and configs — to the
1585
- * package's exports (`@miroshnik/elves/spec/harness` …), its scripts and the knip reporter in GitHub Actions — through
1586
- * the package by the job (`ciFromCopy`), a job whether dependencies are installed is unknown — a `!` line;
1587
- * package.json — `packageScripts`. Only tracked files that name `.agents/skills/`.
1649
+ * Files of the project that lead into the copy (#558, #620): imports of the copy's modules in tests and configs — to the
1650
+ * package's exports (`@miroshnik/elves/spec/harness`, `…/spec/knip-hints` …), its scripts in shell scripts and command
1651
+ * strings of source files — through the package, in GitHub Actions — through the package by the job (`ciFromCopy`), a
1652
+ * job whether dependencies are installed is unknown — a `!` line; package.json — `packageScripts` (`copyRewrite`). Only
1653
+ * tracked files that name `.agents/skills/`; what is left — `copyLeftovers`, in `check` — `copyRefs`.
1588
1654
  */
1589
1655
  function rewriteCopyRefs(root: string) {
1590
1656
  const done: string[] = [];
1591
1657
  const berry = packageManager(root).berry;
1592
1658
  for (const rel of gitGrep(root, ["-l", "-F", "-e", ".agents/skills/"])) {
1593
- const ci = CI_FILE.test(rel);
1594
- if (!ci && !SOURCE_FILE.test(rel)) continue;
1595
1659
  const file = path.join(root, rel);
1596
1660
  const text = readFileSync(file, "utf8");
1597
- const { out, unknown } = ci ? ciFromCopy(text, berry) : { out: text.replace(COPY_IMPORT, (_, q: string, mod: string) => `${q}${packageImport(mod)}${q}`), unknown: [] };
1598
- if (out === text) continue;
1661
+ const rewritten = copyRewrite(rel, text, berry);
1662
+ if (!rewritten || rewritten.out === text) continue;
1663
+ const { out, unknown } = rewritten;
1599
1664
  if (dry) {
1600
1665
  const scripts = berry ? "the package through bunx (Yarn 2+)" : "the package: node_modules/.bin/elves, a job without dependencies — bunx";
1601
- change("~", file, { note: ci ? `the flow copy's scripts → ${scripts}` : `imports of the flow copy → ${PACKAGE}` });
1666
+ change("~", file, { note: CI_FILE.test(rel) ? `the flow copy's scripts → ${scripts}` : SHELL_FILE.test(rel) ? `the flow copy's scripts → ${PACKAGE}` : `imports and scripts of the flow copy → ${PACKAGE}` });
1602
1667
  continue;
1603
1668
  }
1604
1669
  writeFileSync(file, out);
@@ -1630,6 +1695,50 @@ function copyLeftovers(root: string) {
1630
1695
  if (lines.length) note([`Still naming .agents — the flow copy is gone: exceptions for it in linters, formatters and configs aren't needed anymore, a path into it leads to ${PACKAGE} or a skill on the machine; fix by hand:`, ...capped(lines)].join("\n"));
1631
1696
  }
1632
1697
 
1698
+ /**
1699
+ * A path into a file of the flow copy: its canon (CANON, under the former name too) and the flow's skills
1700
+ * (`.agents/skills/<skill>/`). A project's own skill, the manifest and the shim are the thin project's own, `~/.agents`
1701
+ * and `$HOME/.agents` — the machine's.
1702
+ */
1703
+ const copyRef = () => {
1704
+ const dirs = [CANON, FORMER.canon, ...sourceSkills(SRC).map((s) => `.agents/skills/${s}`)].map(locale.escapeRe).join("|");
1705
+ return new RegExp(String.raw`(?<![\w~}-]|~\/|HOME\/|HOME}\/)(?:${dirs})\/`);
1706
+ };
1707
+
1708
+ /**
1709
+ * `check` (#620): a line of the project that leads into a file of the flow copy after the install — a form `update`
1710
+ * doesn't rewrite (a path in an argument array, an import of the copy's canon) — a `!` line with the file, the line
1711
+ * number and its text: without it `check` says "up to date" after the move, and the project's checks fail on a file
1712
+ * that is gone. The line is taken as the install leaves it — what `update` rewrites isn't named; `.gitignore` doesn't
1713
+ * count, its Elves block is the install's. At most MAX_LINES lines, the rest — a count.
1714
+ */
1715
+ function copyRefs(root: string) {
1716
+ const ref = copyRef();
1717
+ const berry = packageManager(root).berry;
1718
+ const found: string[][] = [];
1719
+ for (const rel of gitGrep(root, ["-l", "-F", "-e", ".agents/"])) {
1720
+ if (rel === ".gitignore") continue;
1721
+ const text = readFileSync(path.join(root, rel), "utf8");
1722
+ const after = rel === "package.json" ? scriptsAfterText(text, yarnShell(root)) : rel === "AGENTS.md" ? agentsWith(text) : (copyRewrite(rel, text, berry)?.out ?? text);
1723
+ after.split("\n").forEach((line, i) => ref.test(line) && found.push([rel, `${i + 1}`, line.trim().slice(0, 120)]));
1724
+ }
1725
+ const fix = `a path into the flow copy update doesn't rewrite — a skill's script: ${packageRun(berry)} <script>, a module: ${PACKAGE}/<export>, the canon and skills: ~/.agents; fix by hand`;
1726
+ for (const [rel, n, line] of found.slice(0, MAX_LINES)) change("!", path.join(root, `${rel}:${n}`), { note: `${line} — ${fix}` });
1727
+ if (found.length > MAX_LINES) change("!", path.join(root, "…"), { note: `and ${found.length - MAX_LINES} more lines into the flow copy` });
1728
+ }
1729
+
1730
+ /** package.json with its scripts as the install leaves them (`scriptAfter`); not JSON — as is. */
1731
+ function scriptsAfterText(text: string, yarn: boolean) {
1732
+ try {
1733
+ const pkg = JSON.parse(text) as { scripts?: Record<string, unknown> };
1734
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
1735
+ for (const [name, cmd] of Object.entries(scripts)) if (typeof cmd === "string") scripts[name] = scriptAfter(name, cmd, yarn).want;
1736
+ return JSON.stringify(pkg, null, /^([ \t]+)"/m.exec(text)?.[1] ?? 2);
1737
+ } catch {
1738
+ return text;
1739
+ }
1740
+ }
1741
+
1633
1742
  /**
1634
1743
  * The project's thin flow (#552): a copy of an earlier release goes, then the manifest, the shim, the AGENTS.md block,
1635
1744
  * the cloud hook (#610), the package.json scripts, the files that led into the copy, the package version and the
@@ -1648,7 +1757,8 @@ function installProject(root: string, auto = autoOf(root), language = languageOf
1648
1757
  rewriteCopyRefs(root);
1649
1758
  packageDependency(root, ownVersion());
1650
1759
  gitignoreFlow(root);
1651
- if (!dry) copyLeftovers(root);
1760
+ if (dry) copyRefs(root);
1761
+ else copyLeftovers(root);
1652
1762
  }
1653
1763
 
1654
1764
  /**
@@ -2445,11 +2555,23 @@ function update(global: boolean) {
2445
2555
  ` git add -A -- ${add.map(shWord).join(" ")} && git commit -m "chore(agents): ${commitSubject(root, version)}"`,
2446
2556
  ]
2447
2557
  : []),
2558
+ ...(copy ? ["By hand in the move's task:", ...MOVE_BY_HAND.map((l) => ` - ${l}`)] : []),
2448
2559
  ].join("\n"),
2449
2560
  );
2450
2561
  return 0;
2451
2562
  }
2452
2563
 
2564
+ /**
2565
+ * What the move from a copy leaves by hand (#620) — `update` can't tell it from the text: `update` prints it after the
2566
+ * move, its help names it; what still leads into the copy — `!` lines of `check` (`copyRefs`).
2567
+ */
2568
+ const MOVE_BY_HAND = [
2569
+ "a project's check that knows the queue wrapper by the copy's path: `. .agents/skills/slot/scripts/slot.sh && …` → `. .agents/slot.sh && …`",
2570
+ `the queue variables under the former name in the project's code and configs: ${FORMER.slotEnv.join(", ")} → ${SLOT_ENV.join(", ")}`,
2571
+ "imports that got shorter — the project's formatter",
2572
+ "git add -A before the first run of the project's checks: until then git ls-files lists the copy's removed files",
2573
+ ];
2574
+
2453
2575
  /**
2454
2576
  * What the update brought, for the agent to tell the user: every release after the installed one up to this package's,
2455
2577
  * newest first — the tag, its page and the list its GitHub Release has (`changeItems`); no releases yet or an installer
package/claude/CLAUDE.md CHANGED
@@ -7,8 +7,8 @@ every project, whatever its `CLAUDE.md`.
7
7
  - Session transcripts (`~/.claude/projects/<path-with-dashes>*/`) are the
8
8
  source of actuals for the `est` skill; `~/.claude/settings.json` must have
9
9
  `"cleanupPeriodDays": 365`, otherwise history is cleaned before it's needed.
10
- - The app's worktree names the branch `claude/<slug>-<hash>` — it is renamed
11
- when the task is taken: `github task status <N> "In progress" --branch
10
+ - The app's worktree names the branch `claude/<slug>-<hash>` — renamed when
11
+ the task is taken: `github task status <N> "In progress" --branch
12
12
  <type>/<N>-<slug>` (a bare `git branch -m` fails on `.git/config` locked by
13
13
  a parallel worktree and leaves the upstream behind). The session panel
14
14
  (branch, diff to `main`, "Create PR") reads git, not GitHub: after a merge
@@ -23,8 +23,8 @@ every project, whatever its `CLAUDE.md`.
23
23
  `ScheduleWakeup` on top: an armed wakeup keeps `archive_session` refused
24
24
  even after `stop`.
25
25
  - Merge in auto mode — `gh pr merge <N> --rebase` as a separate call, without
26
- `cd`, `|`, `;`: otherwise the `Bash(gh pr merge *)` rule (set by the owner)
27
- won't match and the classifier rejects the merge.
26
+ `cd`, `|`, `;`: otherwise the owner's `Bash(gh pr merge *)` rule won't
27
+ match and the classifier rejects the merge.
28
28
  - `install -g` puts a `SessionStart` hook into `~/.claude/settings.json`
29
29
  (`startup|resume|clear`): `check --hook` — the machine (canon, skills, hook)
30
30
  and the project's flow with its mode and language (`AGENTS.md`, "First step
@@ -42,7 +42,8 @@ every project, whatever its `CLAUDE.md`.
42
42
  with `self` archives it, ending the conversation: the last action, after
43
43
  the "can be closed" line. Before it — `TaskStop` for each own
44
44
  background task still running: a live one gives "still has live work".
45
- Refused after that — don't retry: the owner archives from the sidebar.
45
+ A dispatcher archives an executor session with its id. Refused — don't
46
+ retry: the owner archives from the sidebar.
46
47
  - Skills — the machine's `~/.claude/skills/<name>` (symlinks to
47
48
  `~/.agents/skills/<name>`), a thin project has no others; the skill
48
49
  directory is `${CLAUDE_SKILL_DIR}`; a same-named machine skill wins over a
@@ -53,7 +54,7 @@ every project, whatever its `CLAUDE.md`.
53
54
  at once a separate call with `dangerouslyDisableSandbox`: no
54
55
  `excludedCommands` entry takes `bun <file>` out. Entries `gh *` and `bash
55
56
  <path>/wait-ci.sh *` (`ci-wait`) do — the owner's step; without them — the
56
- same separate call. The `task-env` skill, section "Docker and the sandbox".
57
+ same separate call. The `task-env` skill, "Docker and the sandbox".
57
58
  - A subagent that writes gets `isolation: worktree`: an isolated session
58
59
  doesn't write into others' worktrees, an agent without it inherits the
59
60
  parent's isolation. An agent cut off (a limit, a dropped session) leaves
@@ -61,7 +62,7 @@ every project, whatever its `CLAUDE.md`.
61
62
  after a base change — dependencies from the lockfile, else typecheck runs
62
63
  on old packages.
63
64
  - In a cloud session (claude.ai/code) the cloud assigns the branch — rename
64
- it or push to another one only with the user's permission
65
+ it or push elsewhere only with the user's permission
65
66
  (`docs/cloud-sessions.md`).
66
67
  - Another task isn't a session card (`spawn_task`, `start_session`): the
67
68
  question «queue #M?», on "yes" its executor — `Agent` with `isolation:
@@ -1750,8 +1750,9 @@ function cloudBrief(io, slug, issue) {
1750
1750
  };
1751
1751
  for (const line of [N.queue.cloud, N.queue.cloudDoc, N.queue.cloudClose, N.queue.cloudDispatcher, N.queue.cloudQuestion])
1752
1752
  io.out(locale_default.fill(line, vars));
1753
- io.out(`Next: the lines above — the cloud session's first prompt as is, the session started from the project folder (the cloud starts from main); ` + `then as a background command: bash ${waitCi()} issue ${number} — a cloud session can't message a local one (docs/cloud-sessions.md), the task's closing is the signal: ` + `PASS — bun --no-env-file ${import.meta.path} task close ${number} (the actual with --write, ${STATUS}, the merged branch), then archive the executor's session where the agent has the tool; ` + `TIMEOUT — the task's ${q(N.sections.questions)} and its ${q(N.comments.state)} comment, the executor's session, then the same wait again`);
1753
+ io.out(`Next: the lines above — the cloud session's first prompt as is, the session started from the project folder (the cloud starts from main); ` + `then as a background command: bash ${waitCi()} issue ${number} — a cloud session can't message a local one (docs/cloud-sessions.md), the task's closing is the signal: ` + `PASS — bun --no-env-file ${import.meta.path} task close ${number} (the actual with --write, ${STATUS}, the merged branch), then archive the executor's session in an auto project where the agent has the tool; ` + `TIMEOUT — the task's ${q(N.sections.questions)} and its ${q(N.comments.state)} comment, the executor's session, then the same wait again`);
1754
1754
  }
1755
+ var SESSION_ARCHIVE = "an executor session whose task closed — the dispatcher archives it in an auto project where the agent has the tool, " + "not one still working in the background or awaiting the owner's answer; outside auto the owner closes it";
1755
1756
  function cmdTaskQueue(io, slug, o) {
1756
1757
  const want = o.type === undefined ? undefined : typeAliases()[o.type.toLowerCase()];
1757
1758
  if (o.type !== undefined && (!want || want === N.issueTypes.epic))
@@ -1852,11 +1853,12 @@ function cmdTaskQueue(io, slug, o) {
1852
1853
  for (const line of waiting)
1853
1854
  io.out(line);
1854
1855
  if (ready.length) {
1855
- io.out(`Next: each ready task above — a background subagent in its own worktree while fewer than ${QUEUE_EXECUTORS} run (a number from the request replaces it); ` + "the indented line is its whole prompt. After each executor's answer — task queue again: new tasks get in.");
1856
+ io.out(`Next: each ready task above — a background subagent in its own worktree while fewer than ${QUEUE_EXECUTORS} run (a number from the request replaces it); ` + `the indented line is its whole prompt. After each executor's answer — task queue again: new tasks get in; ${SESSION_ARCHIVE}.`);
1856
1857
  } else if (running.length)
1857
- io.out("Next: no ready tasks — wait for the executors' answers; after each — task queue again.");
1858
- else
1859
- io.out(`Queue is empty: nothing ready, nothing ${q(N.status.inProgress)} — the dispatcher's summary (merged, questions, the not ready ones above) and the last line; a session with its own task — after it.`);
1858
+ io.out(`Next: no ready tasks — wait for the executors' answers; after each — task queue again; ${SESSION_ARCHIVE}.`);
1859
+ else {
1860
+ io.out(`Queue is empty: nothing ready, nothing ${q(N.status.inProgress)}; ${SESSION_ARCHIVE}. ` + "Then the dispatcher's summary (merged, questions, the not ready ones above) and the last line; a session with its own task — after it.");
1861
+ }
1860
1862
  return 0;
1861
1863
  }
1862
1864
  var AGENT_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]agent-[^\\/]+$/;
@@ -4,11 +4,13 @@
4
4
  * `{ "configurationHints": [{ type, identifier, workspace, file? }] }`. knip's `json` reporter doesn't print them,
5
5
  * and `--treat-config-hints-as-errors` gives only an exit code — the hints themselves aren't visible.
6
6
  *
7
- * knip --reporter json --reporter .agents/skills/spec/scripts/knip-hints.cjs
7
+ * knip --reporter json --reporter node_modules/@miroshnik/elves/skills/spec/scripts/knip-hints.cjs
8
8
  *
9
9
  * The second output line is its own: `deadCode` joins both. CommonJS, not TypeScript: the harness takes the file
10
10
  * path (`report.file`) with a static import — `import.meta` in a module that Playwright and Jest load as CommonJS is
11
- * a syntax error (tests/standards/harness-load); knip loads it under both Node and Bun. Types — `knip-hints.d.cts`.
11
+ * a syntax error (tests/standards/harness-load); knip loads it under both Node and Bun. A project's code takes the
12
+ * path the same way — the package's export `@miroshnik/elves/spec/knip-hints`, as is under Bun and Node, without a
13
+ * build (#620). Types — `knip-hints.d.cts`.
12
14
  */
13
15
  "use strict";
14
16
 
@@ -149,8 +149,9 @@ and prints the same steps:
149
149
 
150
150
  1. `est fact <N>` computes this session's part of the actual from its
151
151
  transcript in the container (linking — the branch `<type>/<N>-…` or the
152
- only `#N` in the first prompt, section "The brief") and prints an
153
- `Actual (cloud)` comment
152
+ only `#N` in the first prompt, section "The brief"; the session's PR in
153
+ its own repository, by the clone's `origin`, doesn't break it, #617) and
154
+ prints an `Actual (cloud)` comment
154
155
  with hours, tokens, a link to the session and a part marker — post it to
155
156
  the issue with a GitHub tool. Project fields (`Actual, h`, tokens, cost)
156
157
  are set by the next local session: `est fact <N> --write` or
@@ -178,8 +179,9 @@ i.e. per executor. So the brief tells the executor not to message the
178
179
  dispatcher and not to archive its session, and the dispatcher waits for the
179
180
  merge's `Closes #N`: `ci-wait issue <N>` as its own background command; PASS
180
181
  — `github task close <N>` locally (the actual with `--write`, `Status`, the
181
- merged `claude/<slug>` branch), then archiving the executor's session where
182
- it has the tool (till then its transcript may be needed for `est`); TIMEOUT —
182
+ merged `claude/<slug>` branch), then archiving the executor's session in an
183
+ auto project where it has the tool (till then its transcript may be needed
184
+ for `est`; the rule — the `github` skill, "Dispatcher"); TIMEOUT —
183
185
  the task's questions section and State comment, the executor's session, the
184
186
  wait again. After the app restarts the queue's `=` line names the wait.
185
187