@miroshnik/elves 2026.1018.1 → 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
 
@@ -179,8 +179,9 @@ i.e. per executor. So the brief tells the executor not to message the
179
179
  dispatcher and not to archive its session, and the dispatcher waits for the
180
180
  merge's `Closes #N`: `ci-wait issue <N>` as its own background command; PASS
181
181
  — `github task close <N>` locally (the actual with `--write`, `Status`, the
182
- merged `claude/<slug>` branch), then archiving the executor's session where
183
- 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 —
184
185
  the task's questions section and State comment, the executor's session, the
185
186
  wait again. After the app restarts the queue's `=` line names the wait.
186
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miroshnik/elves",
3
- "version": "2026.1018.1",
3
+ "version": "2026.1018.2",
4
4
  "type": "module",
5
5
  "description": "Elves — a development flow with AI agents: shared rules and skills for any agent; install — bunx @miroshnik/elves install -g; the installer and skill scripts — TypeScript under Bun, tests — bun test in tests/",
6
6
  "repository": {
@@ -26,6 +26,10 @@
26
26
  "bun": "./skills/spec/scripts/eslint-config.ts",
27
27
  "default": "./dist/spec/scripts/eslint-config.js"
28
28
  },
29
+ "./spec/knip-hints": {
30
+ "types": "./skills/spec/scripts/knip-hints.d.cts",
31
+ "default": "./skills/spec/scripts/knip-hints.cjs"
32
+ },
29
33
  "./github": {
30
34
  "types": "./skills/github/scripts/github.ts",
31
35
  "bun": "./skills/github/scripts/github.ts",
@@ -66,5 +70,5 @@
66
70
  "eslint-plugin-boundaries": "^7.2.0",
67
71
  "knip": "^6.41.0"
68
72
  },
69
- "gitHead": "28a9863fbdf5acfd5706b44b8d3c3ecd87fd39fd"
73
+ "gitHead": "40c5e0387c9bfa5dfb56d6a7f5b05a99333aabac"
70
74
  }
@@ -6,24 +6,23 @@ allowed-tools: Bash(bun *skills/github/scripts/github.ts *) Bash(git remote get-
6
6
 
7
7
  # github — project and tasks by the canon, not from memory
8
8
 
9
- Principles — `AGENTS.md`, section "Task tracking"; the full rules (types,
9
+ Principles — `AGENTS.md`, "Task tracking"; the full rules (types,
10
10
  decision labels, epics, dependencies, the project, milestones, the five
11
11
  epic-refresh checks) and what each command below checks, sets and prints —
12
- `reference.md` next to this file. Done by hand from the reference, projects
13
- drift apart and a task gets created without a status, priority or epic link.
12
+ `reference.md` next to this file. By hand, projects drift apart and tasks
13
+ lack a status, priority or epic link.
14
14
  The script `scripts/github.ts` next to this file checks the project
15
15
  against the canon, fixes what the API can, and does each task step in one
16
16
  command; the agent goes through the UI steps and asks for confirmation on
17
17
  anything destructive. Syntax and a summary of a command —
18
18
  `github <command> --help`. Editing an existing task (Priority, links) and
19
- milestones — `reference.md`, section "Manual commands".
19
+ milestones — `reference.md`, "Manual commands".
20
20
 
21
21
  **In Claude Code — at once a separate unsandboxed call**
22
22
  (`dangerouslyDisableSandbox`): `gh` fails on TLS in the sandbox, `task close`
23
23
  also calls Docker for the task's stacks — the sandbox denies its socket, and
24
- no `excludedCommands` entry takes a script run by `bun` out — checked with
25
- `bun <path>/github.ts project check` too (`claude/CLAUDE.md`; `SKILL.md` of the
26
- task-env skill, "Checked on the machine").
24
+ no `excludedCommands` entry takes a script run by `bun` out
25
+ (`claude/CLAUDE.md`; `SKILL.md` of the task-env skill, "Checked on the machine").
27
26
 
28
27
  ## Project — check and fix
29
28
 
@@ -54,8 +53,8 @@ asking separately; no browser or sign-in — a list of steps with links for the
54
53
  user; the agent doesn't enter a password; after the steps — `check` again;
55
54
  **Confirmation needed** — show the list to the user, after a "yes" —
56
55
  `fix --confirm`; `!` — an error with the reason, most often `gh` lacks a
57
- scope. The eleven items checked, who does what, how to go through the UI steps
58
- and the API's gaps — `reference.md`, "Project check and fix".
56
+ scope. The eleven items checked, who does what, the UI steps and the API's
57
+ gaps — `reference.md`, "Project check and fix".
59
58
 
60
59
  ## Tasks and PRs
61
60
 
@@ -84,7 +83,7 @@ bun --no-env-file <skill dir>/scripts/github.ts pr premerge <PR>
84
83
  | PR merged | in one turn — `task actualize N` and in the background `wait-ci.sh merged <PR>` (it takes the merge SHA itself); a refresh is needed — next turn, in the background, a subagent with the printed two-line prompt; after both — `task close N` (exit code 0 from the wait — right away, don't read the output) |
85
84
  | Task closed without a PR | `task actualize N`, the subagent; then `task close N` |
86
85
  | Session resumed after the app restarted | the `SessionStart` hook runs `task resume` itself — its line is in the context: run the named command, a background wait died with the process |
87
- | "Do all tasks of epic #M", "fix all High bugs", a new epic — the owner's "yes" | the session is a dispatcher, its own task stays: `task queue --epic M` (or `--type`, `--priority`), executors — background subagents; below, "Dispatcher" |
86
+ | "Do all tasks of epic #M", "fix all High bugs", a new epic — the owner's "yes" | the session is a dispatcher, its own task stays: `task queue --epic M` (or `--type`, `--priority`); below, "Dispatcher" |
88
87
  | Another task: work outside the task, a red-main bug, a flow copy's move | `task new`, then the question to the owner «queue #N?»; "yes" — its executor at once, the same as a dispatcher's (`task new` and `task status` in a session with a task print its prompt) |
89
88
  | Won't do, duplicate, replaced by others | `task drop N [--duplicate-of M]` |
90
89
  | PR ready (and after new commits) | `pr labels <PR>` — decision labels for the task and the epic |
@@ -151,26 +150,28 @@ folder, not a worktree: executors (subagents, cloud sessions) start from
151
150
  tasks, each with an indented line — its executor's prompt. In Claude Code: an executor — `Agent` with
152
151
  `isolation: "worktree"` and `run_in_background: true`, the prompt — the
153
152
  indented line as is; at most 3 subagents and 5 cloud sessions at once, the
154
- owner's number replaces either;
153
+ owner's number replaces either; another project's task — an app session in
154
+ its folder (`start_session`, own worktree);
155
155
  `=` — a task in work (In progress, an open PR, handed out to an executor of
156
156
  this session), not launched again. Before each launch — `get_usage`: the 5-hour limit at 85% and more —
157
157
  no new executors, the running ones finish, then `Remaining: N tasks, the
158
- limit`. After each executor's answer — `task queue` again: it also removes
159
- finished executors' worktrees (`+`), not by hand. A question — to the
158
+ limit`. After each executor's answer — `task queue` again: it removes
159
+ finished executors' worktrees (`+`); its last line names the step:
160
+ an executor session whose task closed — the dispatcher archives it in an
161
+ auto project, not one still working in the background or awaiting the
162
+ owner's answer; outside auto the owner closes it. A question — to the
160
163
  owner in text; the answer — ✅ in the task's questions section and to the same
161
164
  executor with `SendMessage`. After the app restarts — each stopped executor
162
- is continued with `SendMessage` by its id, not a new one (`task resume`). Waits for subagents — it answers again. Failed — a
163
- task comment with what stopped, no restart (the owner's restart:
164
- `--assignment` refuses while the previous executor lives); on an external
165
- blocker — *blocked by* on its issue or
166
- the `external blocker` label with the reason (`reference.md`,
167
- "Dependencies"). Queue empty and nothing running — the summary, the last
165
+ is continued with `SendMessage` by its id, not replaced (`task resume`). Waits for subagents — it answers again. Failed — a
166
+ task comment, no restart (the owner's restart:
167
+ `--assignment` refuses while the previous executor lives); external
168
+ blockers — `reference.md`, "Dependencies". Queue empty and nothing running — the summary, the last
168
169
  line (a session with its own task — after it) and, in auto mode, archiving. The executor's side (`--assignment`,
169
170
  a cloud one's: `--cloud`) — `reference.md`, "task queue".
170
171
 
171
172
  ## Git, PRs and merging — mechanics
172
173
 
173
- Principles — `AGENTS.md`, section "Git, PRs and merging"; the full rules —
174
+ Principles — `AGENTS.md`, "Git, PRs and merging"; the full rules —
174
175
  `reference.md`, "Git, PRs and merging — full rules". Waiting for checks, the
175
176
  full SHA and a merge that deploys — `docs/pr-checks.md`.
176
177
 
@@ -809,10 +809,30 @@ stops giving the task out until it's lifted. Queue
809
809
  empty and nothing running — the summary (merged, questions, not ready, new
810
810
  tasks outside the queue), the last line and, in auto mode, archiving; a session
811
811
  with its own task — after it. A running executor isn't stopped (`TaskStop`) to
812
- archive the session: its answer comes first. The app's
813
- child sessions (`start_session`) aren't given out yet (a server flag); when
814
- they are, an executor becomes a session. A cloud executor (claude.ai/code,
815
- #609) — `--assignment N --cloud`, called by the dispatcher itself: the brief,
812
+ archive the session: its answer comes first.
813
+
814
+ The app's sessions are executors too: another project's task — an app session
815
+ in its folder (`start_session` with its own worktree), while a subagent works
816
+ in a worktree of the dispatcher's repository. Such a session works as a session
817
+ of its project, by its mode; the end of its turn comes as the app's note, like
818
+ an answer, — `task queue` again. Archiving, app and cloud executors alike
819
+ (#621): an executor session whose task closed — the dispatcher archives it in
820
+ an auto project, not one still working in the background or awaiting the
821
+ owner's answer; outside auto the owner closes it. Closed — `task close` passed:
822
+ the PR merged, the deploy green, the actual written; the session is still in
823
+ the list. Auto — `"auto": true` in `.agents/elves.json` of the session's
824
+ project. In auto mode a session archives itself after the last line, but not
825
+ always: it ended with a question whose answer went to the dispatcher, or its
826
+ last turn was the wait for the deploy, and the line never reached archiving.
827
+ Not archived: a session with live background work (the app refuses — "still
828
+ has live work"), one awaiting the owner's answer (an archived session leaves
829
+ the list, and the owner wouldn't read its question), a project outside auto
830
+ mode — the owner closes those. The step — the last line of `task queue`, read
831
+ after each answer; in Claude Code — `archive_session` with the session's id,
832
+ refused — not retried (`claude/CLAUDE.md`).
833
+
834
+ A cloud executor (claude.ai/code, #609) — `--assignment N --cloud`, called by
835
+ the dispatcher itself: the brief,
816
836
  the cloud session's first prompt as is — whole, `task queue` refuses in
817
837
  the cloud; `#` only at its task (`docs/cloud-sessions.md`, "The
818
838
  brief"); after the merge — the cloud's close, without messaging the
@@ -820,7 +840,8 @@ dispatcher or archiving its session. The hand-out — without a worktree
820
840
  (`@cloud`). A cloud session can't message a local one, so `Next:` gives the
821
841
  dispatcher the wait for the task's closing — `wait-ci.sh issue N` as a
822
842
  background command; PASS — `task close N`, then archiving the executor's
823
- session; TIMEOUT — the task's questions and State comment, the wait again;
843
+ session in an auto project (above); TIMEOUT — the task's questions and State
844
+ comment, the wait again;
824
845
  after the app restarts the queue's `=` line names the wait.
825
846
 
826
847
  ### task drop
@@ -2350,11 +2350,23 @@ function cloudBrief(io: Io, slug: string, issue: Issue): void {
2350
2350
  io.out(
2351
2351
  `Next: the lines above — the cloud session's first prompt as is, the session started from the project folder (the cloud starts from main); ` +
2352
2352
  `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: ` +
2353
- `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; ` +
2353
+ `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; ` +
2354
2354
  `TIMEOUT — the task's ${q(N.sections.questions)} and its ${q(N.comments.state)} comment, the executor's session, then the same wait again`,
2355
2355
  );
2356
2356
  }
2357
2357
 
2358
+ /**
2359
+ * The dispatcher's step for its executor sessions — the app's (`start_session`: another project's task) and cloud ones
2360
+ * (#621). In auto mode a session archives itself after the last line, but not always: it ended with a question whose
2361
+ * answer went to the dispatcher, or its last turn was the wait for the deploy. The queue is what the dispatcher reads
2362
+ * after each executor's answer, so its `Next:` names the step. Not archived: a session with live background work (the
2363
+ * app refuses), one awaiting the owner's answer (an archived session leaves the list, and the owner wouldn't read its
2364
+ * question), a project outside auto mode — the owner closes those.
2365
+ */
2366
+ const SESSION_ARCHIVE =
2367
+ "an executor session whose task closed — the dispatcher archives it in an auto project where the agent has the tool, " +
2368
+ "not one still working in the background or awaiting the owner's answer; outside auto the owner closes it";
2369
+
2358
2370
  /**
2359
2371
  * The dispatcher's queue: its ready tasks go to background executors (subagents), K at a time; the queue is re-read
2360
2372
  * after each answer, so tasks that appeared meanwhile are picked up. A session with its own task dispatches too (#546:
@@ -2364,6 +2376,7 @@ function cloudBrief(io: Io, slug: string, issue: Issue): void {
2364
2376
  * "Closes #N" among recent PRs) named with its link, or handed out to an executor of this session (`handedOutFile`).
2365
2377
  * Marks the session as a dispatcher (`dispatcherFile`). Before the queue — finished agents' worktrees
2366
2378
  * (`pruneAgentWorktrees`) but those of executors whose handed-out tasks are open and those a subagent still works in.
2379
+ * The last line names archiving finished executor sessions (`SESSION_ARCHIVE`).
2367
2380
  */
2368
2381
  export function cmdTaskQueue(io: Io, slug: string, o: QueueFilter): number {
2369
2382
  const want = o.type === undefined ? undefined : typeAliases()[o.type.toLowerCase()];
@@ -2453,10 +2466,15 @@ export function cmdTaskQueue(io: Io, slug: string, o: QueueFilter): number {
2453
2466
  if (ready.length) {
2454
2467
  io.out(
2455
2468
  `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); ` +
2456
- "the indented line is its whole prompt. After each executor's answer — task queue again: new tasks get in.",
2469
+ `the indented line is its whole prompt. After each executor's answer — task queue again: new tasks get in; ${SESSION_ARCHIVE}.`,
2457
2470
  );
2458
- } else if (running.length) io.out("Next: no ready tasks — wait for the executors' answers; after each — task queue again.");
2459
- else 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.`);
2471
+ } else if (running.length) io.out(`Next: no ready tasks — wait for the executors' answers; after each — task queue again; ${SESSION_ARCHIVE}.`);
2472
+ else {
2473
+ io.out(
2474
+ `Queue is empty: nothing ready, nothing ${q(N.status.inProgress)}; ${SESSION_ARCHIVE}. ` +
2475
+ "Then the dispatcher's summary (merged, questions, the not ready ones above) and the last line; a session with its own task — after it.",
2476
+ );
2477
+ }
2460
2478
  return 0;
2461
2479
  }
2462
2480
 
@@ -534,7 +534,10 @@ monorepo knip takes longer than a test timeout (5 s in Vitest). Without
534
534
  `node_modules/.bin/knip --reporter json --reporter <skill>/knip-hints.cjs` is
535
535
  run (plus `args`); `report` is ready output of the same command (`knip --reporter
536
536
  json --reporter node_modules/@miroshnik/elves/skills/spec/scripts/knip-hints.cjs > .knip.json`);
537
- without the hints line — a failed test with that command. An exit code other
537
+ without the hints line — a failed test with that command. A project's code that
538
+ needs the reporter's path takes it from the reporter itself, not as a string into
539
+ `node_modules`: `import knipHints from "@miroshnik/elves/spec/knip-hints"` —
540
+ `knipHints.file`, under Bun and Node. An exit code other
538
541
  than 0/1 or output that isn't JSON — all the kind tests fail with the reason: a
539
542
  knip crash isn't "no dead code".
540
543
 
@@ -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
 
@@ -27,7 +27,9 @@ bun --no-env-file <skill dir>/scripts/task-env.ts check [<dir>] # the compose f
27
27
  ```
28
28
 
29
29
  Each command's details and examples — `--help` after it; the details behind this file — `reference.md` next to it.
30
- Run the script outside the sandbox — section "Docker and the sandbox".
30
+ Run the script outside the sandbox — section "Docker and the sandbox". A project's own scripts and tests (the stack
31
+ before the dev server, the e2e wrapper, a compose isolation test) run it from the project's package, at its Elves
32
+ version — `bun --no-env-file node_modules/.bin/elves task-env up` — not by a path into a skill directory.
31
33
 
32
34
  The skill's `check -g` line: `proxy *.localhost: running (elves-proxy)`,
33
35
  `not running — start: …` or `Docker unavailable — <reason>`; no Docker on the
@@ -53,9 +53,11 @@ import { fileURLToPath } from "node:url";
53
53
  /**
54
54
  * Help — `--help` or `-h` before `--` (after it — the e2e command's arguments; tests/standards/cli-help): a command's
55
55
  * synopsis (the first paragraph), what it does and examples; `task-env --help` — the synopses of all. The dispatcher takes
56
- * only the commands from here: a command without help can't be called.
56
+ * only the commands from here: a command without help can't be called. A project's scripts and tests run it from the
57
+ * project's package — `elves task-env …` (bin/elves.ts imports `main`, `HELP` and `OVERVIEW` and prints them under its
58
+ * name, #620).
57
59
  */
58
- const HELP: Record<string, string> = {
60
+ export const HELP: Record<string, string> = {
59
61
  up: `task-env up [--profile <p>]...
60
62
 
61
63
  Brings the task's stack up — the Compose project <project>-<N> by the branch <type>/<N>-slug, <project> without a
@@ -125,7 +127,7 @@ Examples:
125
127
  };
126
128
 
127
129
  /** `task-env --help`: what the script is and the synopsis of each command. */
128
- const OVERVIEW = [
130
+ export const OVERVIEW = [
129
131
  "task-env — a task's environment: its Docker Compose stack and the machine's proxy of *.localhost addresses",
130
132
  "",
131
133
  ...Object.values(HELP).map((h) => h.split("\n\n")[0]!.replace(/^/gm, " ")),
@@ -961,7 +963,7 @@ function stackCommand(group: string, args: string[], env: NodeJS.ProcessEnv): nu
961
963
 
962
964
  const COMMANDS: Record<string, (env: NodeJS.ProcessEnv) => Result> = { up, down, status };
963
965
 
964
- function main(argv: string[]): number {
966
+ export function main(argv: string[]): number {
965
967
  const help = helpOf(argv);
966
968
  if (help) return process.stdout.write(help + "\n"), 0;
967
969
  const command = commandOf(argv);