@miroshnik/elves 0.0.0 → 2026.1018.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +310 -0
  2. package/README.md +344 -0
  3. package/bin/ai-dev.ts +7 -0
  4. package/bin/elves.ts +2847 -0
  5. package/bin/shims/timeout +113 -0
  6. package/claude/CLAUDE.md +71 -0
  7. package/dist/github/scripts/github.js +3428 -0
  8. package/dist/spec/scripts/architecture.js +628 -0
  9. package/dist/spec/scripts/eslint-config.js +54 -0
  10. package/dist/spec/scripts/harness-qzb932c8.js +959 -0
  11. package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
  12. package/dist/spec/scripts/harness.js +53 -0
  13. package/dist/spec/scripts/knip-hints.cjs +34 -0
  14. package/docs/ci-concurrency.md +82 -0
  15. package/docs/cloud-sessions.md +204 -0
  16. package/docs/judgment.md +81 -0
  17. package/docs/parallel-checkouts.md +220 -0
  18. package/docs/pr-checks.md +117 -0
  19. package/docs/testing.md +304 -0
  20. package/package.json +70 -1
  21. package/skills/ci-wait/SKILL.md +180 -0
  22. package/skills/ci-wait/scripts/wait-ci.sh +377 -0
  23. package/skills/dashboard/SKILL.md +212 -0
  24. package/skills/dashboard/scripts/dashboard.ts +912 -0
  25. package/skills/dashboard/scripts/sessions.ts +772 -0
  26. package/skills/est/SKILL.md +222 -0
  27. package/skills/est/reference.md +381 -0
  28. package/skills/est/scripts/est.ts +4867 -0
  29. package/skills/github/SKILL.md +206 -0
  30. package/skills/github/locales/en.json +449 -0
  31. package/skills/github/locales/ru.json +449 -0
  32. package/skills/github/reference.md +1073 -0
  33. package/skills/github/scripts/github.ts +4390 -0
  34. package/skills/github/scripts/locale.ts +495 -0
  35. package/skills/github/scripts/package.json +3 -0
  36. package/skills/slot/SKILL.md +145 -0
  37. package/skills/slot/scripts/package.json +3 -0
  38. package/skills/slot/scripts/slot.ts +279 -0
  39. package/skills/spec/SKILL.md +247 -0
  40. package/skills/spec/canon.md +219 -0
  41. package/skills/spec/checks.md +709 -0
  42. package/skills/spec/ci.md +378 -0
  43. package/skills/spec/migration.md +125 -0
  44. package/skills/spec/reference.md +889 -0
  45. package/skills/spec/scripts/architecture.ts +839 -0
  46. package/skills/spec/scripts/c4.ts +184 -0
  47. package/skills/spec/scripts/eslint-config.ts +82 -0
  48. package/skills/spec/scripts/harness.ts +1483 -0
  49. package/skills/spec/scripts/knip-hints.cjs +34 -0
  50. package/skills/spec/scripts/knip-hints.d.cts +17 -0
  51. package/skills/spec/scripts/package-run.ts +22 -0
  52. package/skills/spec/scripts/package.json +3 -0
  53. package/skills/spec/scripts/spec-break.ts +232 -0
  54. package/skills/spec/scripts/spec-claims.ts +198 -0
  55. package/skills/spec/scripts/spec-diff.ts +1111 -0
  56. package/skills/spec/scripts/spec-doc.ts +744 -0
  57. package/skills/spec/scripts/spec-exceptions.ts +233 -0
  58. package/skills/spec/scripts/spec-publish.ts +214 -0
  59. package/skills/spec/scripts/spec-run.ts +235 -0
  60. package/skills/spec/scripts/spec-shard.ts +267 -0
  61. package/skills/spec/scripts/speclib.ts +826 -0
  62. package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
  63. package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
  64. package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
  65. package/skills/task-env/SKILL.md +229 -0
  66. package/skills/task-env/proxy/compose.yaml +48 -0
  67. package/skills/task-env/reference.md +246 -0
  68. package/skills/task-env/scripts/task-env.ts +981 -0
package/bin/elves.ts ADDED
@@ -0,0 +1,2847 @@
1
+ #!/bin/sh
2
+ ':' //; exec bun --no-env-file "$0" "$@"
3
+ /**
4
+ * elves — installs the flow: the shared rules, references and all Elves skills into a project or onto the machine;
5
+ * checks whether an install has fallen behind the latest Elves release, updates it and cuts a release.
6
+ *
7
+ * bunx @miroshnik/elves install — into the project: the git root (or the current directory)
8
+ * bunx @miroshnik/elves install -g — machine-wide: the canon, skills and hook
9
+ * elves install -g — the same from the npm package (npm i -g @miroshnik/elves)
10
+ * bun <Elves clone>/bin/elves.ts install -g --link — from a clone: symlinks to the clone, edits show at once
11
+ * bunx @miroshnik/elves check [-g] — is the install behind: 0 — no, 1 — yes, 2 — can't tell
12
+ * bunx @miroshnik/elves update [-g] — bring the install up to date
13
+ * bunx github:miroshnik/elves <command> — the fallback: the launcher from main (`toRelease`)
14
+ * bun <Elves clone>/bin/elves.ts release — cut a release: a tag and the list of flow changes
15
+ * bun <Elves checkout>/bin/elves.ts pack <tag> — CI on the release tag: the npm package @miroshnik/elves
16
+ * bun --no-env-file node_modules/.bin/elves spec-doc … — a project's package: the skills' scripts at its version
17
+ * elves slot --for <PID> — the machine queue's shim .agents/slot.sh in a project
18
+ *
19
+ * The shebang goes through `sh`: the second line — `exec bun --no-env-file` for sh, a string and a comment for TypeScript
20
+ * — starts Bun without the project's env files however the bin is called (`elves`, `node_modules/.bin/elves`, a
21
+ * package.json script, bunx), and `elves slot '<command>'` doesn't pass their keys on to the command; not `#!/usr/bin/env
22
+ * -S bun --no-env-file` — `env -S` isn't everywhere (busybox) (tests/standards/no-env-file, #592).
23
+ *
24
+ * The layout is as with skills (`npx skills`), on the machine (`-g`, #557): the canon `~/.agents/elves/` (AGENTS.md,
25
+ * claude/CLAUDE.md, docs/*.md) with Claude Code's rules `~/.claude/rules/elves*.md` — they load without the
26
+ * external-imports dialog in every project of the machine — and the global rule files of the other agents linking to it
27
+ * (`agentRules`), skills (`~/.agents/skills/<name>/`, Claude Code — symlinks in `~/.claude/skills`) and the hook. A
28
+ * project is thin (#552): no copy of the flow, the repository holds only `@miroshnik/elves` in devDependencies
29
+ * (PACKAGE: the project's version of the harness and the skills' scripts for its tests and CI), the manifest, the
30
+ * machine queue's shim `.agents/slot.sh`, a block in the project's `AGENTS.md` linking to the canon on the machine and
31
+ * the cloud session's SessionStart hook in `.claude/settings.json` (`cloudHook`: the cloud doesn't read the machine's
32
+ * settings, #610); updating the project is bumping the package version — a few lines in the branch of the current task.
33
+ * A project with a copy of an earlier release loads the canon twice until it moves — the hook names it (`twiceLines`);
34
+ * `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`).
37
+ * `.agents/elves.json` — on the machine the Elves SHA it was installed from, the list of installed skills (by it a
38
+ * reinstall removes skills Elves no longer has and leaves others' alone), for a machine with `--link` — the clone path;
39
+ * in a project — only `auto`: whether the agent runs a task entirely by itself — answers to questions by
40
+ * recommendation, commit, push, fixing CI and merging its own PR on green checks (AGENTS.md, "What I do without asking
41
+ * and what needs a yes"), `language` — the language the agent works in in the project: dialog, tasks, prompts,
42
+ * documents (AGENTS.md, "Language"; the flow is written in English), and `env` — the task stack settings of the
43
+ * task-env skill. `install` in a project sets auto and language — by a question in the terminal or by
44
+ * the flags `--auto` / `--no-auto` and `--lang <code>`; by default auto is "no", the language is `en`, without a
45
+ * terminal and a flag — the previous value, `update` keeps it. An install of a previous version has no `language`
46
+ * field, and the flow before it worked only in Russian: its language is `ru` (`LEGACY_LANGUAGE` of the github skill's
47
+ * language module), `update` writes it. With auto `install` suggests the Claude Code permission rule for the merge
48
+ * (MERGE_HINT) — it doesn't change the machine's settings. The flow isn't installed into an Elves clone (the canon is
49
+ * at its root): `install` writes a single manifest there, with `auto` and `language`.
50
+ * The project's flow files are committed — the ones the project's git ignores (`.agents/`) the install un-ignores with
51
+ * exceptions in an Elves block at the end of `.gitignore`, `check` reports them as a mismatch (`gitignoreFlow`). A
52
+ * project's compose file `check` holds to the task isolation rules
53
+ * with the task-env skill's command (`composeCheck`): a violation is a mismatch too, `update` doesn't fix it; so is a
54
+ * project with standards or architecture without a `test:spec` script — `check` names the lines (`specScript`); a
55
+ * `test` that runs them a second time next to `test:spec` — an `○` line with `test` without them. The
56
+ * project's checks — the `package.json` scripts `lint`, `typecheck`, `test`, `test:*` — the install wraps in the
57
+ * machine queue, the slot skill (`packageScripts`); if such a script calls turbo and turbo.json doesn't pass the queue
58
+ * variables to tasks, it warns with a ready-made line (`turboEnv`).
59
+ *
60
+ * A copy on the machine without `--link` and a project's package version follow Elves releases — tags vYYYY.MM.DD — not
61
+ * main: the bunx package from main is only a launcher — it finds the latest release and restarts from it by SHA
62
+ * (`toRelease`); the project gets the release's npm version (`ownVersion`). The npm package is a release itself — its
63
+ * version (`npmRelease`): `install` from it installs that release without the network, `check` and `update` restart
64
+ * from the latest release's npm package.
65
+ * `check` is the same install as a dry run: the release package lists what it would change. A machine with `--link`
66
+ * is different: the clone is compared with `origin/main` after `git fetch`, `update` does `git pull --ff-only` and
67
+ * re-creates the links with the code of the updated clone.
68
+ * `update` says what changed — the agent tells the user: a copy and a project — every release after the installed one
69
+ * (a project — after its package version), newest first, with the list of changes its GitHub Release has (one
70
+ * function, `changeItems`, builds both); a `--link` clone — the flow commits it pulled.
71
+ *
72
+ * TypeScript under Bun, like the skills' scripts: bunx runs the package's bin by its shebang, and Bun strips types in
73
+ * node_modules too — so the installer imports the skills' values (the language module, the task-env compose files and
74
+ * proxy name) instead of holding copies. No dependencies, only node: APIs.
75
+ */
76
+ import { execFileSync, spawnSync } from "node:child_process";
77
+ import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
78
+ import os from "node:os";
79
+ import path from "node:path";
80
+ import { isatty } from "node:tty";
81
+ import { fileURLToPath } from "node:url";
82
+
83
+ import { packageManager, specRoots } from "../skills/github/scripts/github.ts";
84
+ import locale from "../skills/github/scripts/locale.ts";
85
+ import { PACKAGE_BUNX, PACKAGE_RUN, packageRun } from "../skills/spec/scripts/package-run.ts";
86
+ import { COMPOSE_FILES, formerProxyName, proxyName } from "../skills/task-env/scripts/task-env.ts";
87
+
88
+ const SRC = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, "");
89
+ const SOURCE = "miroshnik/elves";
90
+ /**
91
+ * The npm package: a project gets it in devDependencies — the harness and the skills' scripts at the project's version
92
+ * (`SCRIPTS`, `exports` of package.json); its version is the release tag (`npmVersion`), CI publishes it on the tag.
93
+ */
94
+ const PACKAGE = "@miroshnik/elves";
95
+ /**
96
+ * The repository's name before the rename to Elves (#544): GitHub redirects it, installs of earlier releases still call
97
+ * it — a SessionStart hook with it is ours (`claudeHook`), a `--link` clone's origin with it moves to SOURCE
98
+ * (`cloneOrigin`), a clone with this package name is a clone (`isElvesClone`).
99
+ */
100
+ const LEGACY_SOURCE = "miroshnik/ai-dev";
101
+ /**
102
+ * The command the output names — and the canon, the AGENTS.md block, the shim and the `.gitignore` block with it: the
103
+ * npm package through bunx, then the subcommand (#559). npm is reachable both on the machine and in a cloud session,
104
+ * where `github:` is 403 (#555); the package is a release itself (`npmRelease`), `check` and `update` from it go by
105
+ * the latest release. `bunx github:${SOURCE}` — the launcher from main (`toRelease`) — is the fallback.
106
+ */
107
+ const RUN = `bunx ${PACKAGE}`;
108
+
109
+ /** The install's manifest: `.agents/elves.json`. */
110
+ const MANIFEST = ".agents/elves.json";
111
+ /** The rules canon in a project: a copy of AGENTS.md, claude/CLAUDE.md and docs/*.md. */
112
+ const CANON = ".agents/elves";
113
+ /** Claude Code's rules: symlinks to the canon's AGENTS.md and claude/CLAUDE.md. */
114
+ const RULES = { agents: ".claude/rules/elves.md", claude: ".claude/rules/elves-claude.md" };
115
+ /** The personal configuration directory, from the home directory (tests/standards/config-dir). */
116
+ const CONFIG = ".config/elves";
117
+
118
+ /**
119
+ * The flow's names before the rename to Elves (#545). An install of an earlier release has them: `update` moves each to
120
+ * its new name and replaces a block instead of adding a second one, the skills' scripts read the former manifest and
121
+ * configuration directory until it does, `check` names what is left. A user's variable `update` can't rename — `check
122
+ * -g` names it with the new name; the launcher of an earlier release passes AI_DEV_RELEASE, the hook of an earlier
123
+ * release marks the agent's Bash with AI_DEV_AGENT_SHELL.
124
+ */
125
+ const FORMER = {
126
+ /** The flow's former name itself — in the output about what is left under it. */
127
+ name: "ai-dev",
128
+ manifest: ".agents/ai-dev.json",
129
+ canon: ".agents/ai-dev",
130
+ rules: [".claude/rules/ai-dev.md", ".claude/rules/ai-dev-claude.md"],
131
+ begin: "<!-- ai-dev:begin",
132
+ end: "<!-- ai-dev:end -->",
133
+ giBegin: "# ai-dev:begin",
134
+ giEnd: "# ai-dev:end",
135
+ config: ".config/ai-dev",
136
+ release: "AI_DEV_RELEASE",
137
+ agentShell: "AI_DEV_AGENT_SHELL",
138
+ /** A user's variables under the former name: the new name → the former one. */
139
+ env: { ELVES_CONFIG_DIR: "AI_DEV_CONFIG_DIR", ELVES_PRIVATE: "AI_DEV_PRIVATE", ELVES_PROXY: "AI_DEV_PROXY", ELVES_PROXY_PORT: "AI_DEV_PROXY_PORT" },
140
+ /** The slot skill's queue variables: runner configs and `turbo.json` of projects pass them until renamed. */
141
+ slotEnv: ["AI_DEV_SLOT", "AI_DEV_SLOT_CPUS"],
142
+ };
143
+ /** What `check` adds to a line about a file or a directory under the former name. */
144
+ const FORMER_NOTE = `the flow's former name (${FORMER.name}) — update moves it`;
145
+
146
+ const BEGIN = "<!-- elves:begin";
147
+ const END = "<!-- elves:end -->";
148
+ /**
149
+ * The block in a project's AGENTS.md: the canon and the skills are on the machine (`install -g`), not in the
150
+ * repository — the agents without the machine's rules (Codex and others without a global rules file) read them by it.
151
+ */
152
+ const BLOCK = [
153
+ `${BEGIN} — set by \`${RUN} install\`, don't edit by hand -->`,
154
+ `Shared rules — \`~/${CANON}/AGENTS.md\` on the machine (\`${RUN} install -g\`): read them before starting work (references — \`~/${CANON}/docs/\`).`,
155
+ "Skills — `~/.agents/skills/`.",
156
+ END,
157
+ ].join("\n");
158
+
159
+ /**
160
+ * Claude Code's SessionStart hook: at the start of a session — `check --hook` (machine and project) and the agent's Bash
161
+ * environment (`writeAgentShell`). Claude Code adds the output of a hook with code 0 to the context, with any other
162
+ * code — doesn't, so the code is always 0: both `check --hook` and the fallback `echo` if bunx didn't start.
163
+ * `--silent` — the first install of the package prints bunx's progress to stderr, and the hook's output goes into the
164
+ * context. The timeout keeps the network from holding up the start; an error doesn't block the session — the "First
165
+ * step of a session" rule in AGENTS.md is the safety net.
166
+ */
167
+ const CHECK_FALLBACK = 'echo "elves check unavailable (exit $?): work by the current flow and say so"';
168
+ const HOOK_COMMAND = `bunx --silent github:${SOURCE} check --hook 2>&1 || ${CHECK_FALLBACK}`;
169
+ const HOOK_MATCHER = "startup|resume|clear";
170
+ const HOOK: SessionHook = { matcher: HOOK_MATCHER, hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 60 }] };
171
+
172
+ /** A group of Claude Code's SessionStart hooks in a settings.json. */
173
+ interface SessionHook {
174
+ matcher: string;
175
+ hooks: { type: "command"; command: string; timeout: number }[];
176
+ }
177
+
178
+ /** Flow files in the clone: what `install` installs from it (a git pathspec). */
179
+ const FLOW = ["AGENTS.md", "claude/CLAUDE.md", ":(glob)docs/*.md", "skills"];
180
+ /** What makes a release necessary: the flow and the installer — projects get its code from the release too. */
181
+ const RELEASED = [...FLOW, "bin"];
182
+ /** Release tag: vYYYY.MM.DD, a same-day patch — vYYYY.MM.DD.N. */
183
+ const RELEASE_TAG = /^v(\d{4})\.(\d{2})\.(\d{2})(?:\.([1-9]\d*))?$/;
184
+ /** Elves releases on GitHub: a release's page is `${RELEASES}/tag/<tag>`. */
185
+ const RELEASES = `https://github.com/${SOURCE}/releases`;
186
+ /** Lines of the change list in `check` output — beyond that, "… and N more". */
187
+ const MAX_LINES = 20;
188
+ /** The compose file check — a command of the task-env skill, from the package `check` runs from. */
189
+ const TASK_ENV = path.join(SRC, "skills/task-env/scripts/task-env.ts");
190
+ /** bun start and `docker compose config` take a fraction of a second; the hook has 60 s for everything. */
191
+ const TASK_ENV_TIMEOUT = 30_000;
192
+
193
+ /**
194
+ * Help — `--help` or `-h` anywhere in the arguments (tests/standards/cli-help): a command's synopsis (the first
195
+ * paragraph), what it does and examples; `elves --help` — the synopses of all. Answered before the launcher goes to the
196
+ * release (`toRelease`) — by the launcher's own version: no network, at the cost of lagging behind a newer release. The
197
+ * dispatcher takes only the commands from here (their flags — `FLAGS`) and the skills' scripts (`SCRIPTS`).
198
+ */
199
+ const HELP: Record<string, string> = {
200
+ install: `elves install [--auto | --no-auto] [--lang <code>]
201
+ elves install -g [--link]
202
+
203
+ The flow of the latest Elves release into the project (the git root or the current directory), thin — without a copy:
204
+ ${PACKAGE} of the release in devDependencies of package.json (the project's package manager installs it, the lockfile
205
+ changes too), .agents/elves.json, the machine queue's shim .agents/slot.sh, a block in AGENTS.md linking to the canon
206
+ on the machine and the cloud session's SessionStart hook in .claude/settings.json — in a Claude Code cloud session
207
+ (CLAUDE_CODE_REMOTE=true) it installs a fresh Bun and the flow onto the session's machine and runs check --hook, outside
208
+ it exits at once; the package.json checks (lint, typecheck, test, test:*) go through the shim, the skills' scripts —
209
+ ${PACKAGE_RUN} <script>, without the project's env files; on Yarn 2+, which runs the .ts bin by Node, both go through
210
+ 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
+ In a terminal asks whether the agent does tasks entirely by itself — answers by recommendation, commit, push,
214
+ fixing CI, merge on green checks (auto in .agents/elves.json, default — no); without a terminal — the --auto or
215
+ --no-auto flag, without the flag the previous answer stays; with auto prints the Claude Code permission rule for the
216
+ merge — Bash(gh pr merge *) in ~/.claude/settings.json: the owner adds it, install doesn't change the machine's
217
+ settings. --lang <code> — the project language the agent works in: dialog, tasks, prompts, documents (language in
218
+ .agents/elves.json, a BCP 47 code: ru, en, pt-BR); in a terminal — a question, en by default, without the flag and a
219
+ terminal — the previous one. In an Elves clone writes only auto and language, and its cloud hook — with zsh, the flow
220
+ from the clone itself.
221
+ -g — onto the machine: the canon in ~/.agents/elves with Claude Code's rules ~/.claude/rules/elves*.md and the global
222
+ rule files of Codex, Gemini CLI, Copilot, OpenCode and Amp linking to it (someone else's file stays), the skills
223
+ (~/.agents/skills, ~/.claude/skills) and the SessionStart hook; without a terminal too — a cloud session's project hook
224
+ or setup script (CLAUDE_CODE_REMOTE=true) gets the canon and skills without the hook: the cloud doesn't read
225
+ ~/.claude/settings.json. From the npm package (npm i -g / bun add -g ${PACKAGE}) — the release of its version, without
226
+ the network. --link — from an Elves clone: symlinks to the clone instead of copies, edits show up immediately.
227
+
228
+ Examples:
229
+ elves install --auto --lang en
230
+ elves install -g`,
231
+ check: `elves check [-g]
232
+ elves check --hook
233
+
234
+ Whether the install is behind, changes nothing: 0 — up to date, 1 — behind, the project's git ignores flow files, its
235
+ compose file breaks task isolation or standards and architecture have no test:spec script, 2 — the check is
236
+ unavailable; the machine's copy and a project's package version are compared with the latest release (from the npm
237
+ package — by restarting from the latest release's package), a --link clone — with origin/main. For a project names the
238
+ mode (auto) and the language, holds the compose file to the task isolation rules (❌ per violation, ○ — not checked: no
239
+ docker compose; the task-env skill), names a test that runs standards and architecture again next to test:spec (○, the
240
+ 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
242
+ 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
+ task new command of the github skill, one task per project. For the machine names the task-env proxy of *.localhost
244
+ addresses — running, not running or running
245
+ under the flow's former name, with the command (with Docker on the machine; the exit code stays the same), the user's
246
+ variables under the former name (AI_DEV_*) with the new one, run from the agent's Bash in Claude Code — a session
247
+ without the agent's Bash environment, for a --link clone run from an Elves clone — flow commits not in a release yet.
248
+ --hook — the machine and the project at once for the Claude Code SessionStart hook: the exit code is always 0, errors
249
+ go to the output; writes the agent's Bash environment to $CLAUDE_ENV_FILE — zsh without errors on a glob without matches
250
+ and on =word, timeout from bin/shims; a project with a copy of the canon next to the machine's rules — the canon loads
251
+ twice: names it and the project's update task that moves it to the thin project.
252
+
253
+ Examples:
254
+ elves check
255
+ elves check -g`,
256
+ update: `elves update [-g]
257
+
258
+ Brings it up to date: a --link clone — git pull --ff-only, the machine's copy — install of the latest release: the
259
+ canon, the skills and the hook; a project — install of the latest release: ${PACKAGE} in package.json and the
260
+ 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
267
+ (.agents/ai-dev.json, .agents/ai-dev/, .claude/rules/ai-dev*.md, the blocks in AGENTS.md and .gitignore,
268
+ ~/.config/ai-dev) move to the new ones with the same contents.
269
+
270
+ Examples:
271
+ elves update
272
+ elves update -g`,
273
+ release: `elves release [--dry-run]
274
+
275
+ From an Elves clone: a vYYYY.MM.DD tag on origin/main and a GitHub Release listing the flow changes since the previous
276
+ release; --dry-run — only show them. CI publishes the tag to npm (.github/workflows/npm.yml, pack --help) by trusted
277
+ publishing, without a token; the owner's step once — README, "Installation".
278
+
279
+ Examples:
280
+ elves release --dry-run`,
281
+ pack: `elves pack <tag>
282
+
283
+ From an Elves checkout, in CI on a release tag: the npm package ${PACKAGE} of that release — the version
284
+ from the tag into package.json (vYYYY.MM.DD.N → YYYY.MMDD.N: semver without leading zeros, versions sort as tags), the
285
+ release commit into its gitHead and the modules a project imports (exports of package.json: the harness, the
286
+ architecture model, the ESLint fragment, github) built for Node into dist/; then npm publish by trusted publishing (OIDC,
287
+ no token). Not a release tag — code 2, nothing changes.
288
+
289
+ Examples:
290
+ elves pack v2026.10.15.1`,
291
+ };
292
+
293
+ /**
294
+ * The skills' scripts a project runs at its own Elves version — the package in its devDependencies, without a copy of
295
+ * the flow: `elves spec-doc …` from `node_modules/.bin` runs the script's `main` from this package with the same
296
+ * arguments, in this process; the project's paths (`.agents/elves.json`, `tests/`, the reports) come from the current
297
+ * directory and its git root, as with `bun <copy>/scripts/<script>.ts`. No release launcher (`toRelease`): the version
298
+ * 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`).
301
+ */
302
+ const SCRIPTS: Record<string, { file: string; helpFirst?: boolean }> = {
303
+ ...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
+ slot: { file: "skills/slot/scripts/slot.ts", helpFirst: true },
305
+ };
306
+
307
+ /** A script of SCRIPTS: its help and its entry point. */
308
+ interface Script {
309
+ HELP: string;
310
+ main: (argv: string[]) => number | Promise<number>;
311
+ }
312
+
313
+ const loadScript = (cmd: string): Promise<Script> => import(path.join(SRC, SCRIPTS[cmd]!.file));
314
+
315
+ /**
316
+ * The package's script in the current project — the form of its package.json: on Yarn 2+ through bunx (`packageRun`).
317
+ * The root — by `.git` up from the current directory, without git: help runs no external commands
318
+ * (tests/standards/cli-help).
319
+ */
320
+ const projectRun = () => packageRun(packageManager(locale.projectRoot(process.cwd())).berry);
321
+
322
+ /**
323
+ * A script's help under the elves name: its synopsis and example lines start with the package's script in the project
324
+ * (`projectRun`: `PACKAGE_RUN <command>`, on Yarn 2+ — `PACKAGE_BUNX <command>`) — the agent copies an example as is:
325
+ * 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
+ * global package, if it's on PATH at all (tests/standards/no-env-file).
327
+ */
328
+ const scriptHelp = async (cmd: string, run = projectRun()) => (await loadScript(cmd)).HELP.replace(new RegExp(`^( *)${cmd}(?= |$)`, "gm"), `$1${run} ${cmd}`).trimEnd();
329
+
330
+ /** `elves <script> …`: the script's help or its `main` with the arguments; the exit code is the script's. */
331
+ async function runScript(cmd: string, args: string[]): Promise<number> {
332
+ 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);
335
+ }
336
+
337
+ /** `elves --help`: the synopsis of each command, the scripts' too, and how to run it. */
338
+ async function overview() {
339
+ const synopsis = (h: string) => h.split("\n\n")[0]!.replace(/^/gm, " ");
340
+ const run = projectRun();
341
+ const scripts = await Promise.all(Object.keys(SCRIPTS).map((cmd) => scriptHelp(cmd, run)));
342
+ return [
343
+ "elves — the rules and skills of the Elves flow: install, check, update, release, pack; skill scripts at a project's version",
344
+ "",
345
+ ...Object.values(HELP).map(synopsis),
346
+ ...scripts.map(synopsis),
347
+ "",
348
+ "elves <command> --help — the command's details and examples",
349
+ `Run: ${RUN} <command>; in a project with ${PACKAGE} in devDependencies — ${run} <command>`,
350
+ ].join("\n");
351
+ }
352
+
353
+ /** The help the arguments ask for (`--help`, `-h`): of the command they start with, otherwise the overview; none — undefined. */
354
+ async function helpOf(argv: string[]): Promise<string | undefined> {
355
+ if (!argv.some((a) => a === "--help" || a === "-h")) return undefined;
356
+ const words = `${argv.join(" ")} `;
357
+ const cmd = Object.keys(HELP).find((c) => words.startsWith(`${c} `));
358
+ return cmd ? HELP[cmd] : overview();
359
+ }
360
+
361
+ /**
362
+ * Dry run (`check`): instead of writing to disk the install collects here what it would change, the flow files the
363
+ * project's git ignores (`❌`, the note has the rule, manual — an exception can't un-ignore it), and warnings about
364
+ * project settings the install doesn't touch (`!`, the note says what to fix); null — it writes.
365
+ */
366
+ interface Change {
367
+ op: "+" | "-" | "~" | "❌" | "!";
368
+ path: string;
369
+ note?: string;
370
+ manual?: boolean;
371
+ }
372
+ let dry: Change[] | null = null;
373
+
374
+ /** The release tag the install runs from (`toRelease`); null — not a release: a clone or no releases yet. */
375
+ let release: string | null = null;
376
+
377
+ const note = (s: string) => dry || process.stdout.write(s + "\n");
378
+ const warn = (s: string) => dry || process.stderr.write("!! " + s + "\n");
379
+ /** A mismatch the install didn't fix. */
380
+ const fail = (s: string) => dry || process.stderr.write("❌ " + s + "\n");
381
+
382
+ /**
383
+ * What the install would change: `+` will appear, `-` will be removed, `~` will change, `❌` — git ignores a flow
384
+ * file, `!` — a warning (not falling behind: update won't fix it); a directory ends with `/`.
385
+ */
386
+ const change = (op: Change["op"], p: string, extra: { note?: string; manual?: boolean } = {}) => dry?.push({ op, path: p, ...extra });
387
+
388
+ /** Paths from the project root the install wrote or removed: `update` names them for the commit. */
389
+ const touched: string[] = [];
390
+ const touch = (rel: string) => void touched.push(rel);
391
+
392
+ function lstat(p: string) {
393
+ try {
394
+ return lstatSync(p);
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ function onPath(cmd: string) {
401
+ return (process.env.PATH ?? "").split(path.delimiter).some((d) => d && existsSync(path.join(d, cmd)));
402
+ }
403
+
404
+ function samePath(a: string, b: string) {
405
+ try {
406
+ return realpathSync(a) === realpathSync(b);
407
+ } catch {
408
+ return false;
409
+ }
410
+ }
411
+
412
+ /** git in directory dir; an error throws. */
413
+ function git(dir: string, ...args: string[]) {
414
+ return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
415
+ }
416
+
417
+ /** The first stderr line of a failed command. */
418
+ function reason(e: unknown) {
419
+ const err = e as { stderr?: string | Buffer; message?: string };
420
+ return (String(err.stderr ?? "").trim() || String(err.message)).split("\n")[0] ?? "";
421
+ }
422
+
423
+ const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 7) : null);
424
+
425
+ /**
426
+ * The Elves SHA the install runs from: for a clone — HEAD; for the bunx package — the SHA of the spec bunx installed it
427
+ * by, `github:miroshnik/elves#<sha>` in the package.json of the bunx directory next to node_modules (bunx unpacks the
428
+ * package from GitHub without `.git`, its `.bun-tag` holds only 7 characters of the SHA). The package under its scope
429
+ * (`node_modules/@miroshnik/elves`) — the spec under the full name. The package from main — no SHA in the spec — null:
430
+ * it is only a launcher (`toRelease`). The npm package (`npmRelease`) — the commit `pack` wrote into its `gitHead`: its
431
+ * spec is a version, `npm i -g` has no package.json above it at all.
432
+ */
433
+ function sourceSha(src: string) {
434
+ if (existsSync(path.join(src, ".git"))) {
435
+ try {
436
+ return git(src, "rev-parse", "HEAD");
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+ if (npmRelease(src)) {
442
+ const head: unknown = ownPackage(src).gitHead;
443
+ return typeof head === "string" && /^[0-9a-f]{40}$/.test(head) ? head : null;
444
+ }
445
+ const scope = path.basename(path.dirname(src)).startsWith("@") ? path.basename(path.dirname(src)) : null;
446
+ const nm = scope ? path.dirname(path.dirname(src)) : path.dirname(src);
447
+ if (path.basename(nm) !== "node_modules") return null;
448
+ const name = scope ? `${scope}/${path.basename(src)}` : path.basename(src);
449
+ try {
450
+ const spec: unknown = JSON.parse(readFileSync(path.join(nm, "..", "package.json"), "utf8")).dependencies?.[name];
451
+ return /#([0-9a-f]{40})$/.exec(String(spec ?? ""))?.[1] ?? null;
452
+ } catch {
453
+ return null;
454
+ }
455
+ }
456
+
457
+ /** The package's own package.json; unreadable — empty. */
458
+ function ownPackage(src: string): { name?: unknown; version?: unknown; gitHead?: unknown } {
459
+ try {
460
+ return JSON.parse(readFileSync(path.join(src, "package.json"), "utf8")) ?? {};
461
+ } catch {
462
+ return {};
463
+ }
464
+ }
465
+
466
+ /**
467
+ * The release of the npm package — `npm i -g`, `bun add -g`, a project's devDependencies: the package is a release
468
+ * itself, its version is the release tag (`npmVersion` back, `releaseTag`). A clone, a package from GitHub (the
469
+ * repository's version 0.0.0 — `pack` sets the release version only in CI) — null.
470
+ */
471
+ function npmRelease(src: string): string | null {
472
+ if (existsSync(path.join(src, ".git"))) return null;
473
+ const { name, version } = ownPackage(src);
474
+ return name === PACKAGE && typeof version === "string" ? releaseTag(version) : null;
475
+ }
476
+
477
+ /**
478
+ * The release tag of an npm version — `npmVersion` back: YYYY.MMDD.N → vYYYY.MM.DD.N, .0 — without a patch; not a
479
+ * release version — null.
480
+ */
481
+ function releaseTag(version: string): string | null {
482
+ const m = /^(\d{4})\.(\d{3,4})\.(\d+)$/.exec(version);
483
+ if (!m) return null;
484
+ const pad = (n: number) => String(n).padStart(2, "0");
485
+ const md = Number(m[2]);
486
+ const tag = `v${m[1]}.${pad(Math.floor(md / 100))}.${pad(md % 100)}${m[3] === "0" ? "" : `.${m[3]}`}`;
487
+ return npmVersion(tag) === version ? tag : null;
488
+ }
489
+
490
+ /** Release tags in order: by date, then by patch number; negative — a is older. */
491
+ function compareTags(a: string, b: string) {
492
+ const key = (tag: string) => (RELEASE_TAG.exec(tag) ?? []).slice(1).map((n) => Number(n ?? 0));
493
+ const [ka, kb] = [key(a), key(b)];
494
+ return ka.reduce((r, n, i) => r || n - (kb[i] ?? 0), 0);
495
+ }
496
+
497
+ const lsRemote = (args: string[], cwd?: string) =>
498
+ execFileSync("git", ["ls-remote", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
499
+
500
+ /**
501
+ * Elves releases in ascending order — RELEASE_TAG tags from `git ls-remote --tags remote` (by date, then by patch
502
+ * number); for an annotated tag — the commit SHA, not the tag object's. Other tags don't count. A git error throws.
503
+ * @param remote a URL or a remote name in cwd
504
+ */
505
+ function releases(remote: string, cwd?: string): { tag: string; sha: string }[] {
506
+ const tags = new Map<string, string>();
507
+ for (const line of lsRemote(["--tags", remote], cwd).split("\n")) {
508
+ const [sha, ref = ""] = line.split("\t");
509
+ const m = /^refs\/tags\/([^^]+)(\^\{\})?$/.exec(ref);
510
+ // `tag^{}` comes after the tag: the annotated tag's commit
511
+ if (sha && m?.[1] && RELEASE_TAG.test(m[1]) && (m[2] || !tags.has(m[1]))) tags.set(m[1], sha);
512
+ }
513
+ return [...tags].map(([tag, sha]) => ({ tag, sha })).sort((a, b) => compareTags(a.tag, b.tag));
514
+ }
515
+
516
+ /** The commit of the remote's HEAD — main; an empty repository — null. A git error throws. */
517
+ function mainHead(remote: string) {
518
+ return lsRemote([remote, "HEAD"]).split("\t")[0]?.trim() || null;
519
+ }
520
+
521
+ /** Where releases and their history come from: ELVES_REPO (a mirror, tests) or GitHub. */
522
+ const repoUrl = () => process.env.ELVES_REPO || `https://github.com/${SOURCE}.git`;
523
+
524
+ /**
525
+ * A copy follows Elves releases, not main. bunx installs a package by its spec once and then runs it from its cache
526
+ * without the network: the package from main (`bunx github:miroshnik/elves`) stays as old as its first run. So it is
527
+ * only a launcher: it finds the latest release and restarts from it by the full SHA — a SHA doesn't change, the cache
528
+ * doesn't harm it; no releases yet — by the SHA of main's head. A package installed by a SHA (`sourceSha`: the restart
529
+ * or the user's own pin) works itself; ELVES_RELEASE — the tag the launcher restarted to. A clone installs itself as
530
+ * is. Tags come from ELVES_REPO (a mirror, tests) or GitHub. The launcher is as old as its first run, the release may be
531
+ * older than the launcher: the tag goes under the former name too (FORMER.release) — each reads its own.
532
+ * The npm package is a release itself (`npmRelease`): from it `install` installs its own version — no launcher and no
533
+ * network, as a cloud environment's setup script needs it (`github:` there would install from GitHub, not npm, and in
534
+ * the cloud session itself it is 403); `check` and `update` compare with and bring to the latest release —
535
+ * `toLatestNpm`.
536
+ * @returns the restarted process's code; null — work here
537
+ */
538
+ function toRelease(argv: string[]): number | null {
539
+ if (existsSync(path.join(SRC, ".git"))) return null;
540
+ const own = npmRelease(SRC);
541
+ if (own) return (release = own), argv[0] === "install" ? null : toLatestNpm(own, argv);
542
+ if (sourceSha(SRC)) return (release = process.env.ELVES_RELEASE || process.env[FORMER.release] || null), null;
543
+ let to: { tag?: string; sha: string } | undefined;
544
+ try {
545
+ const head = (sha: string | null) => (sha ? { sha } : undefined);
546
+ to = releases(repoUrl()).at(-1) ?? head(mainHead(repoUrl()));
547
+ } catch (e) {
548
+ throw new Error(`can't find out the latest Elves release — ${reason(e)}`);
549
+ }
550
+ if (!to) return null;
551
+ const env = { ...process.env };
552
+ for (const name of ["ELVES_RELEASE", FORMER.release]) {
553
+ delete env[name];
554
+ if (to.tag) env[name] = to.tag;
555
+ }
556
+ const r = spawnSync("bunx", ["--silent", `github:${SOURCE}#${to.sha}`, ...argv], { stdio: "inherit", env });
557
+ if (r.error) throw new Error(`can't run ${to.tag ? `release ${to.tag}` : `Elves ${short(to.sha)}`} — ${reason(r.error)}`);
558
+ return r.status ?? 2;
559
+ }
560
+
561
+ /**
562
+ * `check` and `update` from the npm package: the latest release — by the tags, as for the launcher (a tag and an npm
563
+ * version are one release, CI publishes on the tag); newer than this package — a restart from its npm package by the
564
+ * exact version, not from GitHub: npm is reachable wherever the package came from. ELVES_RELEASE — the tag the
565
+ * restart went to: the restarted package doesn't look it up a second time. This package is the latest — null.
566
+ * @returns the restarted process's code; null — work here
567
+ */
568
+ function toLatestNpm(own: string, argv: string[]): number | null {
569
+ if (process.env.ELVES_RELEASE === own) return null;
570
+ let latest: { tag: string } | undefined;
571
+ try {
572
+ latest = releases(repoUrl()).at(-1);
573
+ } catch (e) {
574
+ throw new Error(`can't find out the latest Elves release — ${reason(e)}`);
575
+ }
576
+ if (!latest || compareTags(latest.tag, own) <= 0) return null;
577
+ const env: NodeJS.ProcessEnv = { ...process.env, ELVES_RELEASE: latest.tag };
578
+ delete env[FORMER.release];
579
+ const r = spawnSync("bunx", ["--silent", `${PACKAGE}@${npmVersion(latest.tag)}`, ...argv], { stdio: "inherit", env });
580
+ if (r.error) throw new Error(`can't run release ${latest.tag} — ${reason(r.error)}`);
581
+ return r.status ?? 2;
582
+ }
583
+
584
+ /** The symlink dst → target text, as link() writes it. */
585
+ function linkText(target: string, dst: string, root: string) {
586
+ const inRoot = !path.relative(root, target).startsWith("..");
587
+ return inRoot ? path.relative(path.dirname(dst), target) : target;
588
+ }
589
+
590
+ /**
591
+ * Symlink dst → target: inside root — relative (it is committed in a project), outside — absolute (a relative one
592
+ * breaks if the path to root goes through a symlink: macOS /var → /private/var). Replaces a symlink and an empty file;
593
+ * someone else's file or directory — a warning. A symlink that already points there stays untouched: an agent's
594
+ * sandbox refuses writes to `.claude/skills`, and an update without a new skill mustn't fail on it.
595
+ */
596
+ function link(target: string, dst: string, root: string) {
597
+ const st = lstat(dst);
598
+ if (st && !st.isSymbolicLink() && !(st.isFile() && st.size === 0)) {
599
+ return warn(`${path.relative(root, dst)} already exists and isn't a symlink — left as is`), false;
600
+ }
601
+ const text = linkText(target, dst, root);
602
+ const same = Boolean(st?.isSymbolicLink()) && readlinkSync(dst) === text;
603
+ if (dry) {
604
+ if (!same) change(st ? "~" : "+", dst);
605
+ return true;
606
+ }
607
+ if (same) return true;
608
+ if (st) rmSync(dst);
609
+ mkdirSync(path.dirname(dst), { recursive: true });
610
+ symlinkSync(text, dst);
611
+ note(`${path.relative(root, dst)} → ${path.relative(root, target)}`);
612
+ return true;
613
+ }
614
+
615
+ /** Removes a directory or a symlink. */
616
+ const remove = (p: string) => lstat(p) && rmSync(p, { recursive: true, force: true });
617
+
618
+ /** The directory's files (and symlinks) recursively: path from dir → full path. Finder's .DS_Store doesn't count. */
619
+ function walk(dir: string, rel = "", out = new Map<string, string>()) {
620
+ for (const e of readdirSync(path.join(dir, rel), { withFileTypes: true })) {
621
+ if (e.name === ".DS_Store") continue;
622
+ const r = path.join(rel, e.name);
623
+ if (e.isDirectory()) walk(dir, r, out);
624
+ else out.set(r, path.join(dir, r));
625
+ }
626
+ return out;
627
+ }
628
+
629
+ function sameFile(a: string, b: string) {
630
+ const [sa, sb] = [lstatSync(a), lstatSync(b)];
631
+ if (sa.isSymbolicLink() || sb.isSymbolicLink()) return sa.isSymbolicLink() && sb.isSymbolicLink() && readlinkSync(a) === readlinkSync(b);
632
+ return readFileSync(a).equals(readFileSync(b));
633
+ }
634
+
635
+ /** Dry run of a copy: how directory dst differs from the files expected (path in dst → source). */
636
+ function diffTree(expected: Map<string, string>, dst: string) {
637
+ const st = lstat(dst);
638
+ if (!st) return change("+", dst + "/");
639
+ if (!st.isDirectory()) return change("~", dst);
640
+ const actual = walk(dst);
641
+ for (const [rel, src] of expected) {
642
+ const have = actual.get(rel);
643
+ if (!have) change("+", path.join(dst, rel));
644
+ else if (!sameFile(src, have)) change("~", path.join(dst, rel));
645
+ }
646
+ for (const [rel, have] of actual) if (!expected.has(rel)) change("-", have);
647
+ }
648
+
649
+ /** Directory dst — a copy of directory src or, in linkMode, a symlink to it; the previous contents of dst go entirely. */
650
+ function placeDir(src: string, dst: string, root: string, linkMode: boolean) {
651
+ if (dry) {
652
+ if (!linkMode) return diffTree(walk(src), dst);
653
+ if (lstat(dst) && !lstat(dst)?.isSymbolicLink()) return change("~", dst + "/");
654
+ return void link(src, dst, root);
655
+ }
656
+ remove(dst);
657
+ if (linkMode) return void link(src, dst, root);
658
+ mkdirSync(path.dirname(dst), { recursive: true });
659
+ cpSync(src, dst, { recursive: true });
660
+ }
661
+
662
+ /** Elves skills: skills/<name> directories with SKILL.md. */
663
+ function sourceSkills(src: string) {
664
+ return readdirSync(path.join(src, "skills"))
665
+ .filter((n) => existsSync(path.join(src, "skills", n, "SKILL.md")))
666
+ .sort();
667
+ }
668
+
669
+ /**
670
+ * A file or a directory of the install under the flow's former name (FORMER) goes: the install writes its new name.
671
+ * `ours` — for a path that may be someone else's: the rules' symlinks go only if they lead into the former canon.
672
+ */
673
+ function removeFormer(root: string, rel: string, ours = (_: string) => true) {
674
+ const p = path.join(root, rel);
675
+ const st = lstat(p);
676
+ if (!st || !ours(p)) return;
677
+ if (dry) return void change("-", st.isDirectory() ? p + "/" : p, { note: FORMER_NOTE });
678
+ rmSync(p, { recursive: true, force: true });
679
+ touch(rel);
680
+ note(`${rel}${st.isDirectory() ? "/" : ""} — removed: the flow's former name (${FORMER.name})`);
681
+ }
682
+
683
+ /** The rules canon: `.agents/elves/` — a copy of AGENTS.md, claude/CLAUDE.md and docs/*.md or a symlink to the clone. */
684
+ function installRules(src: string, root: string, linkMode: boolean) {
685
+ const canon = path.join(root, CANON);
686
+ removeFormer(root, FORMER.canon);
687
+ if (linkMode) return placeDir(src, canon, root, true);
688
+ const docs = readdirSync(path.join(src, "docs")).filter((f) => f.endsWith(".md") && statSync(path.join(src, "docs", f)).isFile());
689
+ const files = ["AGENTS.md", "claude/CLAUDE.md", ...docs.map((f) => `docs/${f}`)];
690
+ if (dry) return diffTree(new Map(files.map((rel) => [rel, path.join(src, rel)])), canon);
691
+ remove(canon);
692
+ for (const rel of files) {
693
+ mkdirSync(path.dirname(path.join(canon, rel)), { recursive: true });
694
+ cpSync(path.join(src, rel), path.join(canon, rel));
695
+ }
696
+ note(`${path.relative(root, canon)}/ ← AGENTS.md, claude/CLAUDE.md, docs/ (${docs.length})`);
697
+ }
698
+
699
+ /** The install's `.agents/elves.json`. */
700
+ interface Manifest {
701
+ source?: string;
702
+ sha?: string;
703
+ tag?: string;
704
+ clone?: string;
705
+ skills?: string[];
706
+ auto?: boolean;
707
+ language?: string;
708
+ env?: unknown;
709
+ }
710
+
711
+ /**
712
+ * The install's manifest: `.agents/elves.json`, until `update` moves it — the manifest of an earlier release under the
713
+ * flow's former name (FORMER.manifest); missing or broken — empty.
714
+ */
715
+ function readManifest(root: string): Manifest {
716
+ const file = [MANIFEST, FORMER.manifest].map((f) => path.join(root, f)).find((f) => existsSync(f));
717
+ try {
718
+ return file ? JSON.parse(readFileSync(file, "utf8")) : {};
719
+ } catch {
720
+ return {};
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Skills: `.agents/skills/<name>` — a copy (or a symlink to the clone), Claude Code — a symlink from `.claude/skills`.
726
+ * Someone else's directory with the same name is left alone; skills from a previous install that Elves no longer
727
+ * has are removed. `.agents/elves.json` holds the source SHA, the release tag and the installed skills — the manifest
728
+ * under the former name (FORMER) is read and moved into it, its fields carried over; a dry run
729
+ * doesn't compare the SHA and the tag: the install is up to date if what it installs matches (a release that changes
730
+ * only the installer doesn't touch projects). auto and language — the project settings "a task entirely without
731
+ * asking" and the language the agent works in, always written; the machine doesn't have them (undefined). `env` —
732
+ * the project's task stack settings (the task-env skill: HTTP services and their ports): the project writes it, the
733
+ * install carries it over. A dry run doesn't compare them either: editing a field by hand doesn't make the install
734
+ * "behind".
735
+ * The order survives a run cut off midway (an agent's sandbox refuses writes to `.claude/skills`): skills Elves no
736
+ * longer has go while the old manifest still lists them, and the new manifest is written before the copies — a repeat
737
+ * run takes them for the flow's and finishes the install instead of leaving them as someone else's.
738
+ * @returns installed skills
739
+ */
740
+ function installSkills(src: string, root: string, linkMode: boolean, claude: boolean, auto?: boolean, language?: string) {
741
+ const manifestPath = path.join(root, MANIFEST);
742
+ const before = readManifest(root).skills ?? [];
743
+ const names = sourceSkills(src);
744
+ const env = readManifest(root).env;
745
+ const installed = names.filter((name) => {
746
+ const dst = path.join(root, ".agents/skills", name);
747
+ const st = lstat(dst);
748
+ if (!st || st.isSymbolicLink() || before.includes(name)) return true;
749
+ warn(`${path.relative(root, dst)} already exists and isn't an Elves skill — left; remove it to install the Elves skill`);
750
+ return false;
751
+ });
752
+ for (const name of before.filter((n) => !names.includes(n))) {
753
+ const dst = path.join(root, ".agents/skills", name);
754
+ if (dry) {
755
+ if (lstat(dst)) change("-", dst + "/");
756
+ continue;
757
+ }
758
+ remove(dst);
759
+ if (lstat(path.join(root, ".claude/skills", name))?.isSymbolicLink()) rmSync(path.join(root, ".claude/skills", name));
760
+ note(`${path.relative(root, dst)} — removed: Elves no longer has it`);
761
+ }
762
+ const clone = linkMode ? path.resolve(src) : undefined; // a --link machine: check and update find the clone by it
763
+ if (dry) {
764
+ const m = readManifest(root);
765
+ if (m.source !== SOURCE || JSON.stringify(m.skills) !== JSON.stringify(installed) || m.clone !== clone || !existsSync(manifestPath)) change(existsSync(manifestPath) ? "~" : "+", manifestPath);
766
+ } else {
767
+ const sha = sourceSha(src);
768
+ const manifest = { source: SOURCE, ...(sha ? { sha } : {}), ...(release ? { tag: release } : {}), ...(clone ? { clone } : {}), skills: installed, ...(auto === undefined ? {} : { auto }), ...(language === undefined ? {} : { language }), ...(env === undefined ? {} : { env }) };
769
+ mkdirSync(path.dirname(manifestPath), { recursive: true });
770
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
771
+ }
772
+ // after the new one is written: a run cut off in between reads the new one
773
+ removeFormer(root, FORMER.manifest);
774
+ for (const name of installed) placeDir(path.join(src, "skills", name), path.join(root, ".agents/skills", name), root, linkMode);
775
+ if (!linkMode) note(`.agents/skills/ ← ${installed.join(", ")}`);
776
+ if (claude) for (const name of installed) link(path.join(root, ".agents/skills", name), path.join(root, ".claude/skills", name), root);
777
+ return installed;
778
+ }
779
+
780
+ /**
781
+ * The machine queue's shim in a project (`slotShim`): a package.json script sources it before its command, in CI too
782
+ * (there the command goes at once, without a queue).
783
+ */
784
+ const SLOT_SHIM = ".agents/slot.sh";
785
+ /**
786
+ * The shim: `elves slot --for $$` of the project's package, in `if command -v elves` — without elves on PATH (no
787
+ * node_modules yet, a checkout without the package) the command runs as is; elves failed — the script fails with its
788
+ * code, the command doesn't run (#552). The slot skill has no wrapper of its own: its spec runs scripts through this
789
+ * shim (#585).
790
+ */
791
+ const SLOT_SHIM_TEXT = [
792
+ `# Elves: the machine queue for a package.json check (the slot skill); set by \`${RUN} install\`, don't edit by hand.`,
793
+ `# A script sources it: ". ${SLOT_SHIM} && <command>" — slot takes a slot for the script's own shell ($$), held until`,
794
+ "# the shell exits, and the shell exports the queue variables to the command. elves — from node_modules/.bin of the",
795
+ "# project's package: without it on PATH the command runs as is; elves failed — the script fails with its code.",
796
+ "if command -v elves >/dev/null 2>&1; then",
797
+ " ELVES_SLOT_EXPORT=$(elves slot --for $$) || exit",
798
+ ' eval "$ELVES_SLOT_EXPORT"',
799
+ " unset ELVES_SLOT_EXPORT",
800
+ "fi",
801
+ "",
802
+ ].join("\n");
803
+ /** A package.json script wrapped in slot: `. SLOT_SHIM && <command>`; an earlier release's copy — its slot.sh. */
804
+ const SLOT_WRAPPED = /^\. \.agents\/(?:skills\/slot\/scripts\/)?slot\.sh && ([^]*)$/;
805
+ /**
806
+ * A script wrapped with the command a string argument — the copy's slot.ts by any runtime (node too; with Bun's env
807
+ * files too — without `--no-env-file`), or the package's slot on Yarn 2+: `PACKAGE_BUNX slot`, before #608 — `elves
808
+ * slot`, which Yarn 2+ ran by Node; tools that read package.json scripts (knip) don't see the command.
809
+ */
810
+ const SLOT_STRING = new RegExp(String.raw`^(?:(?:node|bun)(?: --no-env-file)? \.agents/skills/slot/scripts/slot\.ts|(?:elves|${locale.escapeRe(PACKAGE_BUNX)}) slot) '((?:[^']|'\\'')*)'$`);
811
+ /** The script's own command, without the slot wrapper of any install. */
812
+ const unslot = (cmd: string) => SLOT_WRAPPED.exec(cmd)?.[1] ?? SLOT_STRING.exec(cmd)?.[1]?.replaceAll("'\\''", "'") ?? cmd;
813
+ /**
814
+ * Yarn 2+ runs package.json scripts in its own shell (`@yarnpkg/shell`) — without `.`, `eval` and `export`, the shim
815
+ * can't be sourced there: the command stays a string argument of the package's slot, hidden from knip, but in the
816
+ * queue. The package there — `PACKAGE_BUNX`: Yarn 2+ runs a `.ts` bin by Node, past its shebang, with any `nodeLinker`,
817
+ * and with PnP there is no `node_modules` (#608). Yarn 2+ or not — the project's package manager by `packageManager` of
818
+ * the github skill (tests/standards/package-manager).
819
+ */
820
+ const yarnShell = (root: string) => packageManager(root).berry;
821
+ /**
822
+ * Check scripts that go through the machine queue: `lint`, `typecheck`, `test`, `test:*`. Not `build` — hosting and
823
+ * Docker run it, where the shim or the package may be missing; not watch — long-running, it would hold a slot for long.
824
+ */
825
+ const slotted = (name: string) => /^(?:lint|typecheck|test)$|^test:/.test(name) && !name.includes("watch");
826
+ /** A package.json script's command through the machine queue: `. SLOT_SHIM && <command>`, on Yarn 2+ — `PACKAGE_BUNX slot '<command>'`. */
827
+ const slotWrap = (yarn: boolean) => (cmd: string) => (yarn ? `${PACKAGE_BUNX} slot '${cmd.replaceAll("'", "'\\''")}'` : `. ${SLOT_SHIM} && ${cmd}`);
828
+
829
+ /**
830
+ * The shim — the same text in every project: an install of an earlier release (a copy) has none, a stale one is
831
+ * rewritten.
832
+ */
833
+ function slotShim(root: string) {
834
+ const file = path.join(root, SLOT_SHIM);
835
+ const have = existsSync(file) ? readFileSync(file, "utf8") : null;
836
+ if (have === SLOT_SHIM_TEXT) return;
837
+ if (dry) return void change(have === null ? "+" : "~", file);
838
+ mkdirSync(path.dirname(file), { recursive: true });
839
+ writeFileSync(file, SLOT_SHIM_TEXT);
840
+ touch(SLOT_SHIM);
841
+ note(`${SLOT_SHIM} — the machine queue's shim: the slot script of the project's package`);
842
+ }
843
+
844
+ /**
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).
848
+ */
849
+ const COPY_SCRIPT = /\bbun(?: --no-env-file)? (?:\.\/)?\.agents\/skills\/(?:spec|slot)\/scripts\/([\w-]+)\.ts\b/g;
850
+ 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. */
852
+ const PACKAGE_KNIP_HINTS = `node_modules/${PACKAGE}/skills/spec/scripts/knip-hints.cjs`;
853
+
854
+ /**
855
+ * A command that ran the copy's scripts — through the package: `<run> <script>`, `PACKAGE_RUN` by default; on Yarn 2+
856
+ * 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.
858
+ */
859
+ 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);
861
+ }
862
+
863
+ /**
864
+ * The project's package.json scripts. Checks — through the machine queue (the slot skill): `slotted` scripts in the
865
+ * root package.json are wrapped in `. SLOT_SHIM && <command>` — any agent and human call them. The script's shell
866
+ * sources the shim: it holds the slot and gets the queue variables, the command stays a plain command of the script —
867
+ * knip sees its binary, and `bun test` gives knip the test files and `preload` as entries; the command gets exactly the
868
+ * environment it would without the wrapper, no env files of Bun. A wrapper of a previous install — the copy's slot.sh,
869
+ * the command a string argument of slot.ts, hidden from knip — is rewritten; on Yarn 2+ the string is the wrapper
870
+ * (`yarnShell`). The copy's scripts in any script lead to the package (`fromCopy`), on Yarn 2+ — through bunx
871
+ * (`packageRun`). The indentation and the trailing newline stay as they were.
872
+ */
873
+ function packageScripts(root: string) {
874
+ const file = path.join(root, "package.json");
875
+ if (!existsSync(file)) return;
876
+ const text = readFileSync(file, "utf8");
877
+ let pkg: { scripts?: Record<string, unknown> };
878
+ try {
879
+ pkg = JSON.parse(text);
880
+ } catch {
881
+ return warn("package.json — not JSON: the project's checks aren't wrapped in the slot queue");
882
+ }
883
+ const scripts = pkg.scripts;
884
+ if (!scripts || typeof scripts !== "object") return;
885
+ const yarn = yarnShell(root);
886
+ const wrap = slotWrap(yarn);
887
+ const run = packageRun(yarn);
888
+ const outside: string[] = [];
889
+ const previous: string[] = [];
890
+ const copy: string[] = [];
891
+ let hidden = false;
892
+ for (const [name, cmd] of Object.entries(scripts)) {
893
+ 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;
897
+ if (cmd === want) continue;
898
+ scripts[name] = want;
899
+ if (own !== inner) copy.push(name);
900
+ if (slotted(name) && inner === cmd) outside.push(name);
901
+ else if (slotted(name) && wrap(inner) !== cmd) previous.push(name), (hidden ||= SLOT_STRING.test(cmd) && !yarn);
902
+ }
903
+ const changed = [...new Set([...outside, ...previous, ...copy])];
904
+ if (!changed.length) return;
905
+ if (dry) {
906
+ const notes = [
907
+ outside.length && `checks outside the slot queue: ${outside.join(", ")}`,
908
+ previous.length && `the slot wrapper of a previous install${hidden ? " — knip doesn't see the command" : ""}: ${previous.join(", ")}`,
909
+ copy.length && `the flow copy's scripts → ${yarn ? "the package through bunx" : "node_modules/.bin/elves"}: ${copy.join(", ")}`,
910
+ ];
911
+ return void change("~", file, { note: notes.filter(Boolean).join("; ") });
912
+ }
913
+ const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? 2;
914
+ writeFileSync(file, JSON.stringify(pkg, null, indent) + (text.endsWith("\n") ? "\n" : ""));
915
+ touch("package.json");
916
+ const wrapped = [...outside, ...previous];
917
+ if (wrapped.length) note(`package.json — checks through the machine queue (slot): ${wrapped.join(", ")}`);
918
+ if (copy.length) note(`package.json — the flow copy's scripts through the package (${run} <script>): ${copy.join(", ")}`);
919
+ }
920
+
921
+ /**
922
+ * Queue variables a command in a slot needs: the runner's share of cores and "already in a slot" for a nested script
923
+ * in slot.
924
+ */
925
+ const SLOT_ENV = ["ELVES_SLOT", "ELVES_SLOT_CPUS"];
926
+ /**
927
+ * The command calls turbo — `turbo run test`, `turbo test`, `npx turbo …` — but not the `--turbo` flag and not the
928
+ * `turbo.json` path.
929
+ */
930
+ const TURBO = /(?:^|[\s;&|(])turbo(?=\s|$)/;
931
+ /** A JSON string goes as is; outside strings — a JSONC comment, then a trailing comma. */
932
+ const JSON_STRING = String.raw`("(?:[^"\\]|\\.)*")`;
933
+ const JSONC_COMMENT = new RegExp(`${JSON_STRING}|//[^\\n]*|/\\*[^]*?\\*/`, "g");
934
+ const TRAILING_COMMA = new RegExp(`${JSON_STRING}|,(?=\\s*[}\\]])`, "g");
935
+
936
+ /** turbo.json is JSONC: comments and trailing commas go, strings stay as they were. */
937
+ const parseJsonc = (text: string) => JSON.parse(text.replace(JSONC_COMMENT, (_, s) => s ?? "").replace(TRAILING_COMMA, (_, s) => s ?? ""));
938
+
939
+ /** A turbo variable mask: `*` — any characters, the rest — literally. */
940
+ function envMatch(mask: string, name: string) {
941
+ const literal = mask.split("*").map((s) => s.replace(/[.+?^$|()[\]{}\\]/g, "\\$&"));
942
+ return new RegExp(`^${literal.join(".*")}$`).test(name);
943
+ }
944
+
945
+ /**
946
+ * turbo 2 in strict env mode (the default) passes to tasks only the variables named in turbo.json: a script in slot
947
+ * that calls turbo loses `ELVES_SLOT_CPUS` on the way — the runner takes the whole machine — and `ELVES_SLOT` — a
948
+ * nested script in slot waits for its own slot. The pass-through is `globalPassThroughEnv`, not `globalEnv`: it isn't
949
+ * part of the cache hash, and `ELVES_SLOT` is different on every run. The project owns turbo.json, and it may have
950
+ * comments (JSONC) — we don't edit it but warn with a ready-made line; in `check` — a `!` line, not falling behind.
951
+ * turbo.json can't be parsed — we stay silent: turbo itself will parse it. A turbo.json that passes the variables only
952
+ * under the flow's former names (FORMER.slotEnv: slot exports both for now) — the same line, which adds the new names
953
+ * and keeps the former ones: the project's runner configs may still read them.
954
+ */
955
+ function turboEnv(root: string) {
956
+ let scripts: unknown;
957
+ try {
958
+ scripts = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts;
959
+ } catch {
960
+ return;
961
+ }
962
+ if (!scripts || typeof scripts !== "object") return;
963
+ const cmds = Object.entries(scripts).flatMap(([name, cmd]) => (slotted(name) && typeof cmd === "string" ? [unslot(cmd)] : []));
964
+ if (!cmds.some((cmd) => TURBO.test(cmd) && !/--env-mode[= ]loose\b/.test(cmd))) return;
965
+ const name = ["turbo.json", "turbo.jsonc"].find((f) => existsSync(path.join(root, f)));
966
+ if (!name) return;
967
+ let cfg;
968
+ try {
969
+ cfg = parseJsonc(readFileSync(path.join(root, name), "utf8"));
970
+ } catch {
971
+ return;
972
+ }
973
+ if (cfg?.envMode === "loose") return;
974
+ const list = (v: unknown) => (Array.isArray(v) ? v.filter((m): m is string => typeof m === "string") : []);
975
+ const masks = [...list(cfg?.globalPassThroughEnv), ...list(cfg?.globalEnv)];
976
+ const negated = (m: string, v: string) => m.startsWith("!") && envMatch(m.slice(1), v);
977
+ const reaches = (v: string) => masks.some((m) => !m.startsWith("!") && envMatch(m, v)) && !masks.some((m) => negated(m, v));
978
+ const missing = SLOT_ENV.filter((v) => !reaches(v));
979
+ if (!missing.length) return;
980
+ const keep = list(cfg?.globalPassThroughEnv).filter((m) => !SLOT_ENV.some((v) => negated(m, v)));
981
+ const line = `"globalPassThroughEnv": [${[...keep, ...missing.filter((v) => !keep.includes(v))].map((m) => JSON.stringify(m)).join(", ")}]`;
982
+ const former = FORMER.slotEnv.filter((v) => reaches(v));
983
+ const why = former.length
984
+ ? `${name} passes the queue variables only under the flow's former names ${former.join(", ")} — runner configs and nested scripts in slot that read ${missing.join(", ")} don't get ${missing.length > 1 ? "them" : "it"}`
985
+ : `${missing.join(", ")} ${missing.length > 1 ? "don't reach" : "doesn't reach"} the runner: turbo in strict env mode passes to tasks only the variables named in ${name}`;
986
+ const text = `${why} — put at its root ${line}`;
987
+ if (dry) change("!", path.join(root, name), { note: text });
988
+ else warn(`${name} — ${text}`);
989
+ }
990
+
991
+ /**
992
+ * Claude Code: rules from `.claude/rules` load by themselves, whatever the CLAUDE.md. The symlinks under the former
993
+ * name go — Claude Code would load the rules twice; only ours, into the former canon.
994
+ */
995
+ function claudeRules(root: string) {
996
+ const intoFormer = (p: string) => Boolean(lstat(p)?.isSymbolicLink()) && path.resolve(path.dirname(p), readlinkSync(p)).startsWith(path.join(root, FORMER.canon) + path.sep);
997
+ for (const rel of FORMER.rules) removeFormer(root, rel, intoFormer);
998
+ link(path.join(root, CANON, "AGENTS.md"), path.join(root, RULES.agents), root);
999
+ link(path.join(root, CANON, "claude/CLAUDE.md"), path.join(root, RULES.claude), root);
1000
+ }
1001
+
1002
+ /**
1003
+ * A SessionStart hook in `<root>/.claude/settings.json`: the user's own settings and hooks stay in place, a hook of a
1004
+ * previous version (`ours`) is replaced, a repeated install doesn't duplicate it. A dry run names it with `what`.
1005
+ * @returns the file is written
1006
+ */
1007
+ function sessionHook(root: string, hook: SessionHook, ours: (h: { command?: unknown }) => boolean, what: string): boolean {
1008
+ const file = path.join(root, ".claude/settings.json");
1009
+ let settings: { hooks?: Record<string, { hooks?: { command?: unknown }[] }[]> } = {};
1010
+ if (existsSync(file)) {
1011
+ try {
1012
+ settings = JSON.parse(readFileSync(file, "utf8"));
1013
+ } catch {
1014
+ return warn(`.claude/settings.json — not JSON: ${what} isn't installed, add it yourself: ${hook.hooks[0]!.command}`), false;
1015
+ }
1016
+ }
1017
+ const groups = settings.hooks?.SessionStart ?? [];
1018
+ const count = groups.flatMap((g) => g.hooks ?? []).filter(ours).length;
1019
+ if (count === 1 && groups.some((g) => JSON.stringify(g) === JSON.stringify(hook))) return false;
1020
+ if (dry) return change(existsSync(file) ? "~" : "+", file, { note: what }), false;
1021
+ const rest = groups.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !ours(h)) })).filter((g) => g.hooks.length);
1022
+ settings.hooks = { ...settings.hooks, SessionStart: [...rest, hook] };
1023
+ mkdirSync(path.dirname(file), { recursive: true });
1024
+ writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
1025
+ note(`.claude/settings.json — ${what}`);
1026
+ return true;
1027
+ }
1028
+
1029
+ /**
1030
+ * The SessionStart hook in `~/.claude/settings.json` (HOOK): a hook of a previous version (with npx too, with the
1031
+ * repository's name before the rename — LEGACY_SOURCE) is replaced. A dry run names what the hook gives: from a
1032
+ * terminal `check -g` can't see the agent's shell, a missing hook is how it names a missing Bash environment
1033
+ * (tests/capabilities/agent-shell).
1034
+ */
1035
+ function claudeHook(home: string) {
1036
+ const ours = ({ command }: { command?: unknown }) => typeof command === "string" && [SOURCE, LEGACY_SOURCE].some((repo) => command.includes(`${repo} check --hook`));
1037
+ sessionHook(home, HOOK, ours, "the SessionStart hook: the flow check and the agent's Bash environment");
1038
+ }
1039
+
1040
+ /**
1041
+ * The cloud session's hook in the project's `.claude/settings.json` (`cloudHook`): a hook of a previous version — by
1042
+ * the cloud guard and the flow check in its command — is replaced; the file goes into the commit with the flow.
1043
+ */
1044
+ function projectHook(root: string, clone: boolean) {
1045
+ const ours = ({ command }: { command?: unknown }) => typeof command === "string" && command.includes("CLAUDE_CODE_REMOTE") && command.includes("check --hook");
1046
+ if (sessionHook(root, cloudHook(clone), ours, "the cloud session's SessionStart hook: Bun, the flow and check --hook")) touch(PROJECT_SETTINGS);
1047
+ }
1048
+
1049
+ /**
1050
+ * The block linking to the rules in the project's AGENTS.md: at the start of the file, a repeated install replaces it.
1051
+ * The block under the flow's former name (FORMER) is replaced in its place: the project's text around it stays, and
1052
+ * there is no second block.
1053
+ */
1054
+ function agentsBlock(root: string) {
1055
+ const file = path.join(root, "AGENTS.md");
1056
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
1057
+ let out = text;
1058
+ let placed = false;
1059
+ for (const [begin, end] of [
1060
+ [BEGIN, END],
1061
+ [FORMER.begin, FORMER.end],
1062
+ ] as const) {
1063
+ const b = out.indexOf(begin);
1064
+ const e = out.indexOf(end, b);
1065
+ if (b < 0 || e < 0) continue;
1066
+ // a second block goes with the empty lines after it
1067
+ out = placed ? out.slice(0, b) + out.slice(e + end.length).replace(/^\n+/, "") : out.slice(0, b) + BLOCK + out.slice(e + end.length);
1068
+ placed = true;
1069
+ }
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`);
1078
+ }
1079
+
1080
+ const GI_BEGIN = "# elves:begin";
1081
+ const GI_END = "# elves:end";
1082
+ const GI_HEAD = `${GI_BEGIN} — flow files git would otherwise ignore; set by \`${RUN} install\`, don't edit by hand`;
1083
+
1084
+ /** `.gitignore` without the Elves block and the block under the flow's former name (FORMER); no block — as is. */
1085
+ function withoutGiBlock(text: string) {
1086
+ let out = text;
1087
+ for (const [begin, end] of [
1088
+ [GI_BEGIN, GI_END],
1089
+ [FORMER.giBegin, FORMER.giEnd],
1090
+ ] as const) {
1091
+ const b = out.indexOf(begin);
1092
+ const e = out.indexOf(end, b);
1093
+ if (b < 0 || e < 0) continue;
1094
+ const rest = [out.slice(0, b).replace(/\n+$/, ""), out.slice(e + end.length).replace(/^\n+|\n+$/g, "")].filter(Boolean).join("\n");
1095
+ out = rest ? rest + "\n" : "";
1096
+ }
1097
+ return out;
1098
+ }
1099
+
1100
+ /** The block goes at the end of `.gitignore`: an exception works only if it comes after the ignoring rule. */
1101
+ function withGiBlock(base: string, lines: string[]) {
1102
+ if (!lines.length) return base;
1103
+ const block = [GI_HEAD, ...lines, GI_END].join("\n") + "\n";
1104
+ return base ? base.replace(/\n*$/, "\n\n") + block : block;
1105
+ }
1106
+
1107
+ /** A path as a `.gitignore` pattern: special characters — literally. */
1108
+ const glob = (p: string) => p.replace(/[\\*?[]/g, "\\$&");
1109
+
1110
+ /**
1111
+ * Order of the block's lines: the directory exception `!/a/`, then `/a/*` — the rest of its contents is ignored again,
1112
+ * then deeper exceptions.
1113
+ */
1114
+ const giKey = (line: string) => line.replace(/^!?\//, "").replace(/\/$/, "/\0").replace(/\/\*$/, "/\x01");
1115
+
1116
+ /** Directories above a path from the root: `a/b/c` → `a`, `a/b`. */
1117
+ const dirsAbove = (p: string) => p.split("/").slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join("/"));
1118
+
1119
+ /**
1120
+ * Which of the paths (from the project root) the project's git ignores: path → the rule `<source>:<line> «<pattern>»`.
1121
+ * Git doesn't name tracked ones — rules don't apply to them. local — a rule from the repository's `.gitignore`, not a
1122
+ * personal one (core.excludesFile, `.git/info/exclude`).
1123
+ */
1124
+ function ignoredBy(root: string, paths: string[]) {
1125
+ const out = new Map<string, { rule: string; local: boolean }>();
1126
+ if (!paths.length) return out;
1127
+ const r = spawnSync("git", ["-C", root, "check-ignore", "-v", "-z", "--stdin"], { input: paths.join("\0") + "\0", encoding: "utf8" });
1128
+ if (r.error) throw r.error;
1129
+ if (r.status !== 0 && r.status !== 1) throw new Error(`git check-ignore — ${String(r.stderr).trim().split("\n")[0]}`);
1130
+ const home = os.homedir();
1131
+ const fields = r.stdout.split("\0");
1132
+ for (let i = 0; i + 3 < fields.length; i += 4) {
1133
+ const [source = "", line = "", pattern = "", p = ""] = fields.slice(i, i + 4);
1134
+ if (pattern.startsWith("!")) continue; // -v names exceptions too: the path isn't ignored
1135
+ const shown = source.startsWith(home + path.sep) ? `~${source.slice(home.length)}` : source;
1136
+ out.set(p, { rule: `${shown}:${line} «${pattern}»`, local: !path.isAbsolute(source) && path.basename(source) === ".gitignore" });
1137
+ }
1138
+ return out;
1139
+ }
1140
+
1141
+ /** The project's Claude Code settings: the cloud session's hook (`projectHook`). */
1142
+ const PROJECT_SETTINGS = ".claude/settings.json";
1143
+
1144
+ /**
1145
+ * What the install puts into a project and what must go into the commit: the manifest, the shim — without it in the
1146
+ * commit a package.json check fails in CI and another checkout (#558, question 4), — AGENTS.md and the settings with
1147
+ * the cloud hook: a cloud session has only what the repository has.
1148
+ */
1149
+ function flowEntries(root: string) {
1150
+ return [MANIFEST, SLOT_SHIM, "AGENTS.md", PROJECT_SETTINGS].map((p) => ({ path: p, dir: false })).filter((e) => lstat(path.join(root, e.path))?.isDirectory() === e.dir);
1151
+ }
1152
+
1153
+ /**
1154
+ * An exception line for the flow copy's directories — the canon (under the former name too), the skills, their
1155
+ * symlinks in `.claude/rules` and `.claude/skills` — not the thin project's `.agents/elves.json` and the chain of its
1156
+ * `.claude/settings.json` (`!/.claude/`, `/.claude/*`).
1157
+ */
1158
+ const COPY_EXCEPTION = new RegExp(`/(?:${[CANON, FORMER.canon, ".agents/skills", ".claude/rules", ".claude/skills"].map((p) => p.replaceAll(".", "\\.")).join("|")})/`);
1159
+
1160
+ /** The Elves block of `.gitignore` (or the one under the former name) has exceptions for the flow copy. */
1161
+ const copyGiBlock = (text: string) =>
1162
+ [
1163
+ [GI_BEGIN, GI_END],
1164
+ [FORMER.giBegin, FORMER.giEnd],
1165
+ ].some(([begin = "", end = ""]) => {
1166
+ const b = text.indexOf(begin);
1167
+ const e = text.indexOf(end, b);
1168
+ return b >= 0 && e >= 0 && COPY_EXCEPTION.test(text.slice(b, e));
1169
+ });
1170
+
1171
+ interface Ignored {
1172
+ rule: string;
1173
+ personal?: boolean;
1174
+ }
1175
+
1176
+ /**
1177
+ * One pass: flow files that git ignores → exception lines and what is ignored (the path — a file or the topmost
1178
+ * ignored directory). A flow directory (`.agents/skills/spec/…/vendor/`) is un-ignored as a whole — everything in it
1179
+ * is ours. A directory above a flow file (`.claude/`) — by the chain `!/.claude/`, `/.claude/*`, `!/.claude/rules/` …
1180
+ * down to the file: the rest of it stays ignored, as before; only for a repository rule — for a personal rule
1181
+ * `/.claude/*` would hide the rest for everyone who doesn't have that rule (personal). The next pass lifts a deeper
1182
+ * pattern.
1183
+ */
1184
+ function giRound(root: string, entries: { path: string; dir: boolean }[]) {
1185
+ const files = entries.flatMap((e) => (e.dir ? [...walk(path.join(root, e.path)).keys()].map((rel) => `${e.path}/${rel}`) : [e.path]));
1186
+ const ignored = ignoredBy(root, files);
1187
+ const lines: string[] = [];
1188
+ const found = new Map<string, Ignored>();
1189
+ if (!ignored.size) return { lines, found };
1190
+ const dirs = ignoredBy(root, [...new Set([...ignored.keys()].flatMap(dirsAbove))]);
1191
+ for (const [file, own] of ignored) {
1192
+ const entry = entries.find((e) => file === e.path || file.startsWith(e.path + "/"));
1193
+ const above = dirsAbove(file);
1194
+ const top = above.find((d) => dirs.has(d));
1195
+ const rule = top ? dirs.get(top) : undefined;
1196
+ if (!entry || !top || !rule) {
1197
+ lines.push(`!/${glob(file)}`);
1198
+ found.set(file, { rule: own.rule });
1199
+ } else if (top === entry.path || top.startsWith(entry.path + "/")) {
1200
+ lines.push(`!/${glob(top)}/`);
1201
+ found.set(top + "/", { rule: rule.rule });
1202
+ } else if (!rule.local) {
1203
+ found.set(top + "/", { rule: rule.rule, personal: true });
1204
+ } else {
1205
+ for (const d of above.slice(above.indexOf(top), dirsAbove(entry.path).length)) lines.push(`!/${glob(d)}/`, `/${glob(d)}/*`);
1206
+ lines.push(`!/${glob(entry.path)}${entry.dir ? "/" : ""}`);
1207
+ found.set(top + "/", { rule: rule.rule });
1208
+ }
1209
+ }
1210
+ return { lines, found };
1211
+ }
1212
+
1213
+ /** Why a path is ignored: the rule and, if an exception can't un-ignore it, what to do. */
1214
+ function ignoredNote(i: Ignored, stuck: boolean) {
1215
+ if (i.personal) return `${i.rule}: the directory is excluded outside the project, an exception in the project's .gitignore would hide the rest of it for everyone — fix the rule`;
1216
+ return stuck ? `${i.rule}: an exception at the end of .gitignore doesn't un-ignore it — fix the rule` : i.rule;
1217
+ }
1218
+
1219
+ /**
1220
+ * Flow files go into the commit: a file the project's git ignores is invisible to another checkout, CI and a cloud
1221
+ * session — without the shim a package.json check fails there. Exceptions — as an Elves block at the end
1222
+ * of the root `.gitignore` (giRound), rebuilt by every install: passes while new lines appear; not needed — removed,
1223
+ * the exceptions of a flow copy too. What an exception can't un-ignore — `❌`. A dry run only names what is ignored and
1224
+ * a block to rebuild. Not git — nothing to check.
1225
+ * @returns `.gitignore` changed
1226
+ */
1227
+ function gitignoreFlow(root: string) {
1228
+ try {
1229
+ git(root, "rev-parse", "--git-dir");
1230
+ } catch {
1231
+ return false;
1232
+ }
1233
+ const entries = flowEntries(root);
1234
+ const file = path.join(root, ".gitignore");
1235
+ if (dry) {
1236
+ for (const [p, i] of giRound(root, entries).found) change("❌", path.join(root, p), { note: ignoredNote(i, false), manual: Boolean(i.personal) });
1237
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
1238
+ if (text.includes(FORMER.giBegin)) change("~", file, { note: `the block under the flow's former name (${FORMER.giBegin.slice(2)}) — update replaces it` });
1239
+ else if (copyGiBlock(text)) change("~", file, { note: "the flow copy's exceptions — update removes them" });
1240
+ return false;
1241
+ }
1242
+ const before = existsSync(file) ? readFileSync(file, "utf8") : "";
1243
+ const put = (text: string) => {
1244
+ if (text === (existsSync(file) ? readFileSync(file, "utf8") : "")) return;
1245
+ if (text) writeFileSync(file, text);
1246
+ else rmSync(file, { force: true });
1247
+ };
1248
+ const base = withoutGiBlock(before);
1249
+ let lines: string[] = [];
1250
+ const fixed = new Map<string, Ignored>();
1251
+ let stuck = new Map<string, Ignored>();
1252
+ try {
1253
+ put(base);
1254
+ for (let pass = 0; ; pass++) {
1255
+ const r = giRound(root, entries);
1256
+ const fresh = r.lines.filter((l) => !lines.includes(l));
1257
+ if (!fresh.length || pass === 10) {
1258
+ stuck = r.found;
1259
+ break;
1260
+ }
1261
+ for (const [p, i] of r.found) fixed.set(p, i);
1262
+ lines = [...new Set([...lines, ...fresh])].sort((a, b) => (giKey(a) < giKey(b) ? -1 : 1));
1263
+ put(withGiBlock(base, lines));
1264
+ }
1265
+ } catch (e) {
1266
+ put(before);
1267
+ return warn(`.gitignore not checked — ${reason(e)}`), false;
1268
+ }
1269
+ const after = withGiBlock(base, lines);
1270
+ put(after);
1271
+ for (const p of stuck.keys()) fixed.delete(p);
1272
+ if (fixed.size) note(`.gitignore — Elves exceptions: git ignored ${[...fixed].map(([p, i]) => `${p} (${i.rule})`).join(", ")}`);
1273
+ else if (after !== before && !lines.length) note(".gitignore — the Elves exceptions block removed: git doesn't ignore flow files");
1274
+ for (const [p, i] of stuck) fail(`${p} — ${ignoredNote(i, true)}`);
1275
+ if (after !== before) touch(".gitignore");
1276
+ return after !== before;
1277
+ }
1278
+
1279
+ /** The git repository root of the current directory; null — not a repository. */
1280
+ function gitRoot() {
1281
+ try {
1282
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
1283
+ } catch {
1284
+ return null;
1285
+ }
1286
+ }
1287
+
1288
+ /** The project root: the git root or the current directory. */
1289
+ function projectRoot() {
1290
+ return gitRoot() ?? process.cwd();
1291
+ }
1292
+
1293
+ /**
1294
+ * An Elves clone: its canon is the root AGENTS.md, the flow needn't be installed into it. The package is PACKAGE; before
1295
+ * it — named after the repository, `elves`, and the repository's name before the rename (LEGACY_SOURCE): a clone or a
1296
+ * branch that hasn't pulled the rename yet is a clone too.
1297
+ */
1298
+ function isElvesClone(root: string) {
1299
+ try {
1300
+ const name: unknown = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).name;
1301
+ return name === PACKAGE || [SOURCE, LEGACY_SOURCE].some((repo) => repo.split("/")[1] === name);
1302
+ } catch {
1303
+ return false;
1304
+ }
1305
+ }
1306
+
1307
+ /** Whether the agent runs a task entirely by itself — `auto` of the project manifest; no field — "no". */
1308
+ const autoOf = (root: string) => readManifest(root).auto === true;
1309
+
1310
+ /** A BCP 47 language code: the language and optional subtags — `ru`, `en`, `pt-BR`, `zh-Hant`. */
1311
+ const LANGUAGE_CODE = locale.LANGUAGE_CODE;
1312
+
1313
+ /**
1314
+ * The language the agent works in in the project — via the github skill's language module, the same one the skills'
1315
+ * scripts use: `language` of the manifest; a manifest without the field (an install of a previous version) — `ru`, no
1316
+ * manifest — `en`, the flow's language.
1317
+ */
1318
+ const languageOf = (root: string) => locale.projectLanguage(root);
1319
+
1320
+ /** Lines with the project's mode and language in `check` output: the session start (hook) sees them. */
1321
+ const modeLines = (root: string) => [` tasks: ${autoOf(root) ? "entirely by itself" : "with permission"}`, ` language: ${languageOf(root)}`];
1322
+
1323
+ const CLONE_NOTE = "an Elves clone: the flow isn't installed into it — the canon is at its root";
1324
+
1325
+ /**
1326
+ * An Elves clone: of the install it gets only the mode and the language, a manifest of two fields, and its cloud hook
1327
+ * (`cloudHook`) — the clone carries it in its commit, the install writes the same; the task stack settings (`env`), if
1328
+ * the clone has them, are carried over as in a project; the manifest under the former name goes.
1329
+ */
1330
+ function installClone(root: string, auto: boolean, language: string) {
1331
+ const file = path.join(root, MANIFEST);
1332
+ const env = readManifest(root).env;
1333
+ mkdirSync(path.dirname(file), { recursive: true });
1334
+ writeFileSync(file, JSON.stringify({ auto, language, ...(env === undefined ? {} : { env }) }, null, 2) + "\n");
1335
+ removeFormer(root, FORMER.manifest);
1336
+ note(`${MANIFEST} — auto: ${auto}, language: ${language}; ${CLONE_NOTE}`);
1337
+ projectHook(root, true);
1338
+ }
1339
+
1340
+ /**
1341
+ * The npm version of this package — the release the install runs from (`toRelease`: the tag the launcher restarted to,
1342
+ * the npm package's own version); a clone and the launcher from main without releases have none (#558, question 5).
1343
+ */
1344
+ const ownVersion = () => (release ? npmVersion(release) : null);
1345
+
1346
+ /** This package in the output: the release tag, otherwise its SHA. */
1347
+ const ownLabel = () => release ?? short(sourceSha(SRC)) ?? "SHA unknown";
1348
+
1349
+ /** The project's PACKAGE in package.json — devDependencies or dependencies: the spec; none — undefined. */
1350
+ function projectPackage(root: string): string | undefined {
1351
+ try {
1352
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
1353
+ const spec: unknown = pkg?.devDependencies?.[PACKAGE] ?? pkg?.dependencies?.[PACKAGE];
1354
+ return typeof spec === "string" ? spec : undefined;
1355
+ } catch {
1356
+ return undefined;
1357
+ }
1358
+ }
1359
+
1360
+ /** The project's package version for the output: the release tag of an exact version, otherwise the spec as is. */
1361
+ const shownPackage = (spec: string | undefined) => (spec === undefined ? `without ${PACKAGE}` : (releaseTag(spec) ?? spec));
1362
+
1363
+ /**
1364
+ * A project with a copy of the flow of an earlier release: the canon `.agents/elves/` (under the former name too) or a
1365
+ * manifest of a copy — the installed skills, the SHA; a thin project's manifest has only the mode, the language, env.
1366
+ */
1367
+ function isCopy(root: string) {
1368
+ const m = readManifest(root);
1369
+ return [CANON, FORMER.canon, FORMER.manifest].some((p) => lstat(path.join(root, p))) || ["source", "sha", "tag", "skills"].some((k) => k in m);
1370
+ }
1371
+
1372
+ /** Why the copy goes: the line of `install` and `update` about it. */
1373
+ const COPY_NOTE = "the flow copy — the canon and the skills are on the machine (install -g), the scripts in the package";
1374
+
1375
+ /**
1376
+ * A copy of the flow of an earlier release goes (#558): the canon `.agents/elves/` (under the former name too), the skills
1377
+ * the manifest lists — without the list (an install before it) the ones Elves has — from `.agents/skills`, the symlinks
1378
+ * of Claude Code to them in `.claude/skills` and to the canon in `.claude/rules`; the project's own skills and files
1379
+ * stay (#558, question 6), emptied directories go. The manifest that lists the skills is rewritten after them
1380
+ * (`projectManifest`): a run cut off midway is finished by a repeat.
1381
+ */
1382
+ function removeCopy(root: string) {
1383
+ if (!isCopy(root)) return;
1384
+ const listed = readManifest(root).skills;
1385
+ const named = Array.isArray(listed) ? listed.filter((s): s is string => typeof s === "string") : sourceSkills(SRC);
1386
+ const into = (dirs: string[]) => (p: string) => Boolean(lstat(p)?.isSymbolicLink()) && dirs.some((d) => (path.resolve(path.dirname(p), readlinkSync(p)) + path.sep).startsWith(path.join(root, d) + path.sep));
1387
+ const rules = [RULES.agents, RULES.claude, ...FORMER.rules].filter((rel) => into([CANON, FORMER.canon])(path.join(root, rel)));
1388
+ const skills = named.filter((name) => lstat(path.join(root, ".agents/skills", name)));
1389
+ const links = named.filter((name) => into([`.agents/skills/${name}`])(path.join(root, ".claude/skills", name)));
1390
+ const canons = [CANON, FORMER.canon].filter((rel) => lstat(path.join(root, rel)));
1391
+ if (dry) {
1392
+ // the skills — a line per directory, not per skill: the change list is capped, and a copy has a dozen of them
1393
+ for (const rel of rules) change("-", path.join(root, rel));
1394
+ if (skills.length) change("-", path.join(root, ".agents/skills") + "/", { note: `the flow's skills: ${skills.join(", ")}` });
1395
+ if (links.length) change("-", path.join(root, ".claude/skills") + "/", { note: `the symlinks to them: ${links.join(", ")}` });
1396
+ for (const rel of canons) change("-", path.join(root, rel) + "/");
1397
+ return;
1398
+ }
1399
+ const paths = [...rules, ...links.map((n) => `.claude/skills/${n}`), ...skills.map((n) => `.agents/skills/${n}`), ...canons];
1400
+ for (const rel of paths) rmSync(path.join(root, rel), { recursive: true, force: true }), touch(rel);
1401
+ const gone = [...rules, ...(skills.length ? [`.agents/skills/ (${skills.join(", ")})`] : []), ...(links.length ? [".claude/skills/ (their symlinks)"] : []), ...canons.map((rel) => `${rel}/`)];
1402
+ for (const rel of [".agents/skills", ".claude/skills", ".claude/rules", ".claude"]) {
1403
+ const p = path.join(root, rel);
1404
+ if (lstat(p)?.isDirectory() && !readdirSync(p).length) rmSync(p, { recursive: true });
1405
+ }
1406
+ if (gone.length) note(`${gone.join(", ")} — removed: ${COPY_NOTE}`);
1407
+ }
1408
+
1409
+ /** The fields of a project's manifest; the rest are a copy's (`isCopy`). */
1410
+ const PROJECT_FIELDS = ["auto", "language", "env"];
1411
+
1412
+ /**
1413
+ * The project's manifest: the mode, the language and the task stack settings (`env`, the task-env skill — the project
1414
+ * writes it, the install carries it over); the fields of a copy go, the manifest under the former name moves into it.
1415
+ * A dry run doesn't compare the values: auto and language edited by hand don't make the install behind.
1416
+ */
1417
+ function projectManifest(root: string, auto: boolean, language: string) {
1418
+ const file = path.join(root, MANIFEST);
1419
+ const m = readManifest(root);
1420
+ if (dry) {
1421
+ if (!existsSync(file) || Object.keys(m).some((k) => !PROJECT_FIELDS.includes(k))) change(existsSync(file) ? "~" : "+", file);
1422
+ } else {
1423
+ const text = JSON.stringify({ auto, language, ...(m.env === undefined ? {} : { env: m.env }) }, null, 2) + "\n";
1424
+ if (!existsSync(file) || readFileSync(file, "utf8") !== text) {
1425
+ mkdirSync(path.dirname(file), { recursive: true });
1426
+ writeFileSync(file, text);
1427
+ touch(MANIFEST);
1428
+ }
1429
+ }
1430
+ // after the new one is written: a run cut off in between reads the new one
1431
+ removeFormer(root, FORMER.manifest);
1432
+ }
1433
+
1434
+ /** How long the project's package manager may install the dependencies: a big project with a cold cache — minutes. */
1435
+ const PM_TIMEOUT = 600_000;
1436
+
1437
+ /**
1438
+ * The project's version of the package — PACKAGE of this release (`ownVersion`) in devDependencies of package.json, an
1439
+ * exact version (#558, question 1): the harness and the skills' scripts of its tests and CI. The edit keeps the
1440
+ * indentation and the key order, a new key goes to its alphabetical place; in dependencies it stays there. Then the
1441
+ * project's package manager (`packageManager` of the github skill) installs it and updates its lockfile; without a
1442
+ * lockfile — only package.json and a line with the command: the install doesn't create a lockfile the project doesn't
1443
+ * keep. The manager failed (no network, an agent's sandbox) — a warning with the command: package.json is right, the
1444
+ * lockfile follows it. No package.json — nothing to pin: no scripts or tests on JS. No version (a clone, the launcher
1445
+ * from main without releases) — the dependency stays as is, a missing one is a warning (#558, question 5).
1446
+ */
1447
+ function packageDependency(root: string, version: string | null) {
1448
+ const file = path.join(root, "package.json");
1449
+ if (!existsSync(file)) return;
1450
+ const text = readFileSync(file, "utf8");
1451
+ let pkg: Record<string, unknown>;
1452
+ try {
1453
+ pkg = JSON.parse(text);
1454
+ } catch {
1455
+ return; // packageScripts names it
1456
+ }
1457
+ const object = (v: unknown) => (v && typeof v === "object" ? (v as Record<string, unknown>) : undefined);
1458
+ const section = object(pkg.dependencies)?.[PACKAGE] !== undefined ? "dependencies" : "devDependencies";
1459
+ const deps = object(pkg[section]) ?? {};
1460
+ const have = deps[PACKAGE];
1461
+ if (!version) {
1462
+ if (have === undefined) warn(`package.json — ${PACKAGE} isn't added: this installer isn't a release (a clone or no releases yet) — a release adds it: ${RUN} install`);
1463
+ return;
1464
+ }
1465
+ if (have === version) return;
1466
+ const was = typeof have === "string" ? `${have} → ` : "";
1467
+ if (dry) return void change("~", file, { note: `${PACKAGE} ${was || "none → "}${version}` });
1468
+ const entries = Object.entries(deps);
1469
+ const at = entries.findIndex(([name]) => name > PACKAGE);
1470
+ if (have === undefined) entries.splice(at < 0 ? entries.length : at, 0, [PACKAGE, version]);
1471
+ pkg[section] = have === undefined ? Object.fromEntries(entries) : { ...deps, [PACKAGE]: version };
1472
+ const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? 2;
1473
+ writeFileSync(file, JSON.stringify(pkg, null, indent) + (text.endsWith("\n") ? "\n" : ""));
1474
+ touch("package.json");
1475
+ const pm = packageManager(root);
1476
+ const cmd = `${pm.name} install`;
1477
+ if (!pm.lockfile) return note(`package.json — ${PACKAGE} ${was}${version}; no lockfile — install the dependencies: ${cmd}`);
1478
+ const r = spawnSync(pm.name, ["install"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: PM_TIMEOUT });
1479
+ if (r.error || r.status !== 0) {
1480
+ const why = r.error ? r.error.message : (`${r.stderr}\n${r.stdout}`.split("\n").find((l) => /error/i.test(l)) ?? `exit code ${r.status ?? r.signal}`).trim();
1481
+ return warn(`package.json — ${PACKAGE} ${was}${version}, but ${pm.lockfile} isn't updated: ${cmd} — ${why}; run it yourself`);
1482
+ }
1483
+ touch(pm.lockfile);
1484
+ note(`package.json, ${pm.lockfile} — ${PACKAGE} ${was}${version} (${cmd})`);
1485
+ }
1486
+
1487
+ /**
1488
+ * Lines of the project's tracked files that git grep finds by args, outside the flow copy and its symlinks (`.agents/`,
1489
+ * `.claude/skills/`, `.claude/rules/` — git greps a symlink's target path): the hook runs check at every session start,
1490
+ * git grep reads the index, not every file. Not git or nothing — none.
1491
+ */
1492
+ function gitGrep(root: string, args: string[]): string[] {
1493
+ const outside = [".agents", ".claude/skills", ".claude/rules"].map((p) => `:(exclude)${p}`);
1494
+ const r = spawnSync("git", ["-C", root, "grep", "-I", ...args, "--", ".", ...outside], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
1495
+ return !r.error && r.status === 0 ? r.stdout.split("\n").filter(Boolean) : [];
1496
+ }
1497
+
1498
+ /** Source files whose imports may lead into the copy: JavaScript and TypeScript, configs too. */
1499
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1500
+ /** A workflow or an action of GitHub Actions: the copy's scripts go there by path. */
1501
+ 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;
1504
+ /** The package's export of a copy's module: spec/scripts/harness → @miroshnik/elves/spec/harness, github/scripts/github → @miroshnik/elves/github. */
1505
+ const packageImport = (mod: string) => `${PACKAGE}/${mod.startsWith("spec/") ? mod.replace("/scripts/", "/") : "github"}`;
1506
+
1507
+ /**
1508
+ * A step that installs the project's dependencies: `bun`, `npm`, `pnpm` or `yarn` with `install`, `ci`, `i`, a bare
1509
+ * `yarn`; a global install (`-g`, `--global`) brings a tool, not them.
1510
+ */
1511
+ const INSTALL_STEP = /(?:^|[\s;&|(])(?:(?:bun|npm|pnpm|yarn)\s+(?:install|ci|i)(?![\w-])(?![^\n;&|]*\s(?:-g|--global)(?![\w-]))|yarn(?=[ \t]*(?:$|[;&|)]|--(?:frozen-lockfile|immutable)\b)))/m;
1512
+
1513
+ /**
1514
+ * Whether each job of a workflow installs the project's dependencies (`Bun.YAML`): a `run` step does (`INSTALL_STEP`) —
1515
+ * true; a local action (`uses: ./…`) may — null, unknown; otherwise false. Not a workflow (a composite action) or
1516
+ * not YAML — null: every job unknown.
1517
+ */
1518
+ function jobInstalls(text: string): Map<string, boolean | null> | null {
1519
+ let jobs: unknown;
1520
+ try {
1521
+ jobs = (Bun.YAML.parse(text) as { jobs?: unknown } | null)?.jobs;
1522
+ } catch {
1523
+ return null;
1524
+ }
1525
+ if (!jobs || typeof jobs !== "object") return null;
1526
+ return new Map(
1527
+ Object.entries(jobs).map(([id, job]): [string, boolean | null] => {
1528
+ const steps: unknown = (job as { steps?: unknown } | null)?.steps;
1529
+ const all = (Array.isArray(steps) ? steps : []) as ({ run?: unknown; uses?: unknown } | null)[];
1530
+ if (all.some((s) => typeof s?.run === "string" && INSTALL_STEP.test(s.run))) return [id, true];
1531
+ return [id, all.some((s) => typeof s?.uses === "string" && s.uses.startsWith("./")) ? null : false];
1532
+ }),
1533
+ );
1534
+ }
1535
+
1536
+ /**
1537
+ * The job of each line of a workflow — the key at the first indentation under the top-level `jobs:` and the lines to
1538
+ * the next one; outside the jobs — undefined. Lines of block scalars are deeper than the job's key.
1539
+ */
1540
+ function jobOfLines(lines: string[]): (string | undefined)[] {
1541
+ let inJobs = false;
1542
+ let indent = -1;
1543
+ let job: string | undefined;
1544
+ return lines.map((line) => {
1545
+ const [, lead = "", rest = ""] = /^( *)(.*)$/.exec(line) ?? [];
1546
+ if (rest && !rest.startsWith("#")) {
1547
+ if (!lead) {
1548
+ inJobs = /^["']?jobs["']?\s*:\s*(?:#.*)?$/.test(rest);
1549
+ indent = -1;
1550
+ job = undefined;
1551
+ } else if (inJobs) {
1552
+ if (indent < 0) indent = lead.length;
1553
+ if (lead.length === indent) job = /^(["']?)(.+?)\1\s*:(?:\s|$)/.exec(rest)?.[2];
1554
+ }
1555
+ }
1556
+ return job;
1557
+ });
1558
+ }
1559
+
1560
+ /**
1561
+ * A GitHub Actions file with the copy's scripts — through the package by its job (#597): a job that installs the
1562
+ * project's dependencies — `PACKAGE_RUN`, the form of package.json; one that doesn't (the `spec-publish` and
1563
+ * `spec-diff` jobs of earlier `ci.md`) — `PACKAGE_BUNX`. Unknown — a local action, a composite action, a line outside
1564
+ * the jobs, not YAML — `PACKAGE_BUNX`, which works either way, and the job in `unknown` for a fix by hand (an empty
1565
+ * string — the file itself). On Yarn 2+ (`berry`) — `PACKAGE_BUNX` in every job, the form of its package.json: with PnP
1566
+ * `yarn install` puts no `node_modules/.bin` (#608).
1567
+ */
1568
+ function ciFromCopy(text: string, berry: boolean) {
1569
+ const installs = jobInstalls(text);
1570
+ const lines = text.split("\n");
1571
+ const jobs = jobOfLines(lines);
1572
+ const unknown = new Set<string>();
1573
+ const out = lines.map((line, i) => {
1574
+ const job = jobs[i];
1575
+ const has = job === undefined ? null : (installs?.get(job) ?? null);
1576
+ const now = fromCopy(line, has && !berry ? PACKAGE_RUN : PACKAGE_BUNX);
1577
+ if (has === null && !berry && now !== fromCopy(line)) unknown.add(job ?? "");
1578
+ return now;
1579
+ });
1580
+ return { out: out.join("\n"), unknown: [...unknown] };
1581
+ }
1582
+
1583
+ /**
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/`.
1588
+ */
1589
+ function rewriteCopyRefs(root: string) {
1590
+ const done: string[] = [];
1591
+ const berry = packageManager(root).berry;
1592
+ 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
+ const file = path.join(root, rel);
1596
+ 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;
1599
+ if (dry) {
1600
+ 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}` });
1602
+ continue;
1603
+ }
1604
+ writeFileSync(file, out);
1605
+ touch(rel);
1606
+ done.push(rel);
1607
+ for (const job of unknown) {
1608
+ warn(`${rel}${job ? `, job ${job}` : ""} — whether the job installs the project's dependencies is unknown: the flow copy's scripts → ${PACKAGE_BUNX} <script>, it works without them; it installs them — ${PACKAGE_RUN} <script>, fix by hand`);
1609
+ }
1610
+ }
1611
+ if (done.length) note(`${done.join(", ")} — imports and scripts of the flow copy → ${PACKAGE}`);
1612
+ }
1613
+
1614
+ /**
1615
+ * A mention of `.agents` that isn't the thin project's own: the manifest, the shim, `~/.agents` and `$HOME/.agents` of
1616
+ * the machine aren't one.
1617
+ */
1618
+ const COPY_MENTION = /(?<![\w~}-]|~\/|HOME\/|HOME}\/)\.agents(?![\w-])(?!\/(?:elves\.json|slot\.sh)\b)/;
1619
+
1620
+ /**
1621
+ * What still names `.agents` once the copy is gone: exceptions `.agents/**` in linters, formatters and configs aren't
1622
+ * needed anymore, a path into the copy no rule rewrote — a line each for the agent to fix by hand (#558). `.gitignore`
1623
+ * doesn't count: its Elves block is the install's.
1624
+ */
1625
+ function copyLeftovers(root: string) {
1626
+ const lines = gitGrep(root, ["-n", "-F", "-e", ".agents"]).flatMap((l) => {
1627
+ const m = /^(.+?):(\d+):(.*)$/.exec(l);
1628
+ return m?.[1] && m[1] !== ".gitignore" && COPY_MENTION.test(m[3] ?? "") ? [` ${m[1]}:${m[2]}: ${(m[3] ?? "").trim().slice(0, 120)}`] : [];
1629
+ });
1630
+ 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
+ }
1632
+
1633
+ /**
1634
+ * The project's thin flow (#552): a copy of an earlier release goes, then the manifest, the shim, the AGENTS.md block,
1635
+ * the cloud hook (#610), the package.json scripts, the files that led into the copy, the package version and the
1636
+ * `.gitignore` exceptions. The copy goes first — its manifest lists the skills to remove; the package manager runs
1637
+ * after every package.json edit. auto and language — the project's mode and language; only `install` sets them (a flag
1638
+ * or a question in the terminal), `update` and `check` go with the manifest's previous value.
1639
+ */
1640
+ function installProject(root: string, auto = autoOf(root), language = languageOf(root)) {
1641
+ removeCopy(root);
1642
+ projectManifest(root, auto, language);
1643
+ slotShim(root);
1644
+ agentsBlock(root);
1645
+ projectHook(root, false);
1646
+ packageScripts(root);
1647
+ turboEnv(root);
1648
+ rewriteCopyRefs(root);
1649
+ packageDependency(root, ownVersion());
1650
+ gitignoreFlow(root);
1651
+ if (!dry) copyLeftovers(root);
1652
+ }
1653
+
1654
+ /**
1655
+ * Agents' global rule files: the agent is on the machine — its directory in the home directory (or the command on
1656
+ * PATH), the file — the canon's AGENTS.md. Cursor has no such file.
1657
+ */
1658
+ const AGENT_RULES = [
1659
+ { dir: ".codex", cmd: "codex", file: ".codex/AGENTS.md" },
1660
+ { dir: ".gemini", file: ".gemini/GEMINI.md" },
1661
+ { dir: ".copilot", file: ".copilot/copilot-instructions.md" },
1662
+ { dir: ".config/opencode", file: ".config/opencode/AGENTS.md" },
1663
+ { dir: ".config/amp", file: ".config/amp/AGENTS.md" },
1664
+ ];
1665
+
1666
+ /**
1667
+ * The other agents get the machine's canon by their global rule file — a symlink to the canon's AGENTS.md, as before
1668
+ * #180: Codex glues it to the project's AGENTS.md, Gemini CLI, Copilot, OpenCode and Amp read it the same way. A file
1669
+ * is ours when it is missing, empty or a symlink into the canon — the current one or under the flow's former name
1670
+ * (FORMER.canon, a previous install); someone else's file or symlink — the user's own rules — stays, with a line
1671
+ * where the canon is.
1672
+ */
1673
+ function agentRules(home: string, has: (dir: string, cmd?: string) => boolean) {
1674
+ const canons = [CANON, FORMER.canon].map((c) => path.join(home, c) + path.sep);
1675
+ const ours = (file: string) => {
1676
+ const st = lstat(file);
1677
+ if (!st || (st.isFile() && st.size === 0)) return true;
1678
+ return st.isSymbolicLink() && canons.some((c) => path.resolve(path.dirname(file), readlinkSync(file)).startsWith(c));
1679
+ };
1680
+ for (const { dir, cmd, file } of AGENT_RULES) {
1681
+ if (!has(dir, cmd)) continue;
1682
+ if (ours(path.join(home, file))) link(path.join(home, CANON, "AGENTS.md"), path.join(home, file), home);
1683
+ else note(`~/${file} — the user's own rules: left as is; the canon — ~/${CANON}/AGENTS.md`);
1684
+ }
1685
+ }
1686
+
1687
+ /**
1688
+ * The URL of a GitHub origin on the repository's name before the rename (LEGACY_SOURCE, the name is case-insensitive) —
1689
+ * the same URL on SOURCE: the form, the credentials, the port and `.git` stay; another repository or host — null.
1690
+ */
1691
+ function renamedOrigin(url: string): string | null {
1692
+ const m = /^(.*?(?:^|[@/])(?:ssh\.)?github\.com(?::\d+)?[:/])([^/]+\/[^/]+?)((?:\.git)?\/?)$/i.exec(url.trim());
1693
+ return m?.[2]?.toLowerCase() === LEGACY_SOURCE ? `${m[1]}${SOURCE}${m[3]}` : null;
1694
+ }
1695
+
1696
+ /**
1697
+ * A `--link` clone's origin — on the repository's current name: GitHub redirects git by the old name, but the skills
1698
+ * find the repository and answer "a checkout of repository X?" by the origin URL (tests/standards/origin-repo), and
1699
+ * GraphQL by the old name may not find it. The clone is cloned by the old name — `install -g --link` and `update -g`
1700
+ * (it reinstalls the links) move it, `check -g` names it. `.git/config` is shared with the clone's worktrees, a parallel
1701
+ * git may hold its lock: a failed move is a warning with the command, the install goes on.
1702
+ */
1703
+ function cloneOrigin(clone: string) {
1704
+ let url;
1705
+ try {
1706
+ // the URL as configured: `remote get-url` would expand the user's insteadOf rules
1707
+ url = git(clone, "config", "--get", "remote.origin.url");
1708
+ } catch {
1709
+ return;
1710
+ }
1711
+ const to = renamedOrigin(url);
1712
+ if (!to) return;
1713
+ if (dry) return void change("~", clone, { note: `origin ${url} → ${to}: the repository is renamed` });
1714
+ try {
1715
+ git(clone, "remote", "set-url", "origin", to);
1716
+ note(`clone ${clone}: origin → ${to} — the repository is renamed`);
1717
+ } catch (e) {
1718
+ warn(`clone ${clone}: origin isn't moved to ${to} — ${reason(e)}; run: git -C ${clone} remote set-url origin ${to}`);
1719
+ }
1720
+ }
1721
+
1722
+ /** A Claude Code cloud environment (claude.ai/code): its setup script and its sessions have this variable. */
1723
+ const remote = () => process.env.CLAUDE_CODE_REMOTE === "true";
1724
+
1725
+ /**
1726
+ * The machine (#557): the canon `~/.agents/elves/` — a copy or, with `--link`, a symlink to the clone — with Claude
1727
+ * Code's rules `~/.claude/rules/elves*.md` (the same `installRules` and `claudeRules` as in a project: what a previous
1728
+ * install left under the flow's former name moves), the other agents' rule files (`agentRules`), skills and the hook.
1729
+ * Claude Code is on the machine — `~/.claude`, `claude` on PATH or a cloud session: its project hook (`cloudHook`) or
1730
+ * its environment's setup script runs in a fresh home. The cloud doesn't read `~/.claude/settings.json` — no hook there.
1731
+ */
1732
+ function installGlobal(src: string, linkMode: boolean) {
1733
+ const home = os.homedir();
1734
+ const has = (dir: string, cmd = "") => existsSync(path.join(home, dir)) || (cmd !== "" && onPath(cmd));
1735
+ const claude = has(".claude", "claude") || remote();
1736
+ installRules(src, home, linkMode);
1737
+ installSkills(src, home, linkMode, claude);
1738
+ agentRules(home, has);
1739
+ if (linkMode) cloneOrigin(src);
1740
+
1741
+ if (claude) {
1742
+ claudeRules(home);
1743
+ if (!remote()) claudeHook(home);
1744
+ // install.sh installed ~/.claude/CLAUDE.md as a symlink to claude/CLAUDE.md, and that one imported AGENTS.md
1745
+ const old = path.join(home, ".claude/CLAUDE.md");
1746
+ if (lstat(old)?.isSymbolicLink() && readlinkSync(old).endsWith(path.join("claude", "CLAUDE.md"))) {
1747
+ if (dry) change("-", old);
1748
+ else rmSync(old), note(".claude/CLAUDE.md — the old install.sh symlink removed: the canon comes from ~/.claude/rules");
1749
+ }
1750
+ }
1751
+
1752
+ if (has(".cursor")) note(`Cursor: sees the skills in ~/.agents/skills; it has no global rules file — paste ~/${CANON}/AGENTS.md into Settings → Rules → User Rules`);
1753
+
1754
+ // The default location of the personal configuration. ELVES_CONFIG_DIR is deliberately not read here: the
1755
+ // variable overrides the whole directory and needs no symlink — the skills' scripts look where it points
1756
+ // (tests/standards/config-dir).
1757
+ moveConfig(home);
1758
+ const priv = process.env.ELVES_PRIVATE || process.env[FORMER.env.ELVES_PRIVATE];
1759
+ if (priv) {
1760
+ const dst = path.join(home, CONFIG);
1761
+ const st = lstat(dst);
1762
+ if (st && !st.isSymbolicLink()) warn(`~/${CONFIG} already exists and isn't a symlink — move its contents to ${priv} and remove it`);
1763
+ else if (dry) {
1764
+ if (!st || readlinkSync(dst) !== priv) change(st ? "~" : "+", dst);
1765
+ } else {
1766
+ if (st) rmSync(dst);
1767
+ mkdirSync(path.dirname(dst), { recursive: true });
1768
+ symlinkSync(priv, dst);
1769
+ note(`${CONFIG} → ${priv}`);
1770
+ }
1771
+ }
1772
+ }
1773
+
1774
+ /**
1775
+ * The personal configuration directory under the flow's former name (FORMER.config in the home directory) moves to
1776
+ * `~/.config/elves`: a directory — whole, a symlink (to a private checkout, `ELVES_PRIVATE`) — the same symlink. In its
1777
+ * place stays a symlink to the new one: the scripts of earlier releases — a project copy `update` hasn't brought yet —
1778
+ * keep the same directory, and the queue, the pins and the registry don't split in two; the skills' scripts read the
1779
+ * former directory until the move (tests/standards/config-dir). Both exist — not merged by the script: a warning with
1780
+ * what to do.
1781
+ */
1782
+ function moveConfig(home: string) {
1783
+ const from = path.join(home, FORMER.config);
1784
+ const to = path.join(home, CONFIG);
1785
+ const st = lstat(from);
1786
+ if (!st) return;
1787
+ const backLink = path.basename(to);
1788
+ if (st.isSymbolicLink() && path.resolve(path.dirname(from), readlinkSync(from)) === to) return;
1789
+ if (lstat(to)) {
1790
+ const text = `~/${FORMER.config} and ~/${CONFIG} both exist — move what ~/${FORMER.config} has into ~/${CONFIG} yourself and replace it with a symlink: ln -sfn ${backLink} ~/${FORMER.config}`;
1791
+ if (dry) return void change("!", from, { note: text });
1792
+ return void warn(text);
1793
+ }
1794
+ if (dry) return void change("~", from + (st.isDirectory() ? "/" : ""), { note: `${FORMER_NOTE} to ~/${CONFIG}` });
1795
+ if (st.isSymbolicLink()) symlinkSync(readlinkSync(from), to), rmSync(from);
1796
+ else renameSync(from, to);
1797
+ symlinkSync(backLink, from);
1798
+ note(`${FORMER.config} → ${CONFIG}: the flow's former name; ${FORMER.config} stays a symlink to it for the scripts of earlier releases`);
1799
+ }
1800
+
1801
+ /** A dry-run install: what it would change. */
1802
+ function dryRun(fn: () => void) {
1803
+ dry = [];
1804
+ try {
1805
+ fn();
1806
+ return dry;
1807
+ } finally {
1808
+ dry = null;
1809
+ }
1810
+ }
1811
+
1812
+ /** List lines, capped at MAX_LINES. */
1813
+ function capped(lines: string[]) {
1814
+ return lines.length > MAX_LINES ? [...lines.slice(0, MAX_LINES), ` … and ${lines.length - MAX_LINES} more`] : lines;
1815
+ }
1816
+
1817
+ /**
1818
+ * Dry-run changes as ` ~ path` lines; on the machine the path is from `~/`, outside the home directory (a `--link`
1819
+ * clone) — absolute.
1820
+ */
1821
+ function changeLines(changes: Change[], root: string, global: boolean) {
1822
+ const shown = (p: string) => {
1823
+ const rel = path.relative(root, p);
1824
+ return !global ? rel : rel.startsWith("..") ? path.resolve(p) : `~/${rel}`;
1825
+ };
1826
+ return changes.map((c) => ` ${c.op} ${shown(c.path)}${c.path.endsWith("/") ? "/" : ""}${c.note ? ` — ${c.note}` : ""}`);
1827
+ }
1828
+
1829
+ /**
1830
+ * behind — falls behind: update will install something new; copy — the project keeps a copy of the flow: update moves
1831
+ * it to a thin one, in a task of its own; ignored — the project's git ignores flow files: update
1832
+ * will add exceptions; compose — the project's compose file breaks task isolation: update won't fix it; spec — the
1833
+ * project has standards or architecture without a `test:spec` script: update won't add it; run — the text names a
1834
+ * command to run.
1835
+ */
1836
+ interface Result {
1837
+ code: 0 | 1 | 2;
1838
+ text: string;
1839
+ absent?: boolean;
1840
+ behind?: boolean;
1841
+ copy?: boolean;
1842
+ ignored?: boolean;
1843
+ compose?: boolean;
1844
+ spec?: boolean;
1845
+ run?: boolean;
1846
+ }
1847
+
1848
+ /**
1849
+ * The project's compose file against the task isolation rules (the task-env skill, tests/capabilities/task-env): a `❌`
1850
+ * line per violation, `○` — not checked (no docker compose, the config doesn't parse): that isn't a violation, and the
1851
+ * exit code of `check` doesn't change. No compose file — no lines, the script doesn't start. The script runs under the
1852
+ * same Bun as the installer.
1853
+ */
1854
+ function composeCheck(root: string): { lines: string[]; violations: boolean } {
1855
+ const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
1856
+ if (!file) return { lines: [], violations: false };
1857
+ const r = spawnSync(process.execPath, ["--no-env-file", TASK_ENV, "check", root], { encoding: "utf8", timeout: TASK_ENV_TIMEOUT });
1858
+ const notChecked = (why: string) => ({ lines: [` ○ ${file}: not checked — ${why}`], violations: false });
1859
+ if (r.error) return notChecked(`task-env: ${r.error.message}`);
1860
+ if (r.status === 0) return { lines: [], violations: false };
1861
+ const lines = r.stdout.split("\n").filter(Boolean).map((l) => ` ${l}`);
1862
+ if (r.status === 1 || (r.status === 2 && lines.length)) return { lines, violations: r.status === 1 };
1863
+ return notChecked(`task-env: ${r.stderr.trim().split("\n")[0] || `exit code ${r.status ?? r.signal}`}`);
1864
+ }
1865
+
1866
+ /**
1867
+ * A project with a copy moves to a thin one in a task of its own, one per project: a task session doesn't take it —
1868
+ * the copy's thousands of lines go, imports and CI change. A thin project's update is a few lines — the session's own
1869
+ * task does it. The title is constant, in the project language (the `updateTask` key of this package's github skill
1870
+ * dictionary): `task new` refuses a second open task with the same title and names the open one — an open update task
1871
+ * of the copy is the same work (#558, question 3). The script — not the project's copy: the line shows exactly when
1872
+ * the project has one, and its script may not know the flags printed here (`--branch-type` — since v2026.10.11, #431).
1873
+ */
1874
+ const updateTaskLine = (root: string) =>
1875
+ `The task — one per project, an open one is named by the refusal: bun --no-env-file ${githubScript(false)} task new --title "${locale.names(languageOf(root)).updateTask}" --branch-type chore`;
1876
+
1877
+ /**
1878
+ * Test runners of a package.json command — called directly or by the package manager: the `test:spec` command over the
1879
+ * spec roots and the filter that excludes a root from `test` (Vitest `--exclude` adds to the config's; Jest's
1880
+ * `--testPathIgnorePatterns` replaces the config's list; `bun test --path-ignore-patterns` repeats).
1881
+ */
1882
+ const RUNNERS = {
1883
+ vitest: { re: /^(?:(?:npx|bunx|yarn|pnpm(?: exec)?) )?vitest\b/, spec: "vitest run", exclude: (dir: string) => `--exclude '${dir}/**'` },
1884
+ jest: { re: /^(?:(?:npx|bunx|yarn|pnpm(?: exec)?) )?jest\b/, spec: "jest", exclude: (dir: string) => `--testPathIgnorePatterns=${dir}/` },
1885
+ bun: { re: /^bun test\b/, spec: "bun test", exclude: (dir: string) => `--path-ignore-patterns='${dir}/**'` },
1886
+ };
1887
+
1888
+ /**
1889
+ * The project's fast checks: with standards or architecture (`specRoots` of the github skill) the root package.json
1890
+ * needs a `test:spec` script — without it `github pr premerge` checks the merge with a moved main by typecheck alone
1891
+ * or by nothing, and a standard broken by two green PRs reaches main (#442). A mismatch that update doesn't fix — the
1892
+ * commands are the project's: a `❌` line with the ready scripts, in the slot wrapper `packageScripts` puts (its
1893
+ * `slotWrap`). `test:spec` — the runner of the `test` command, otherwise by the dependencies (Vitest, Jest, `bun test`
1894
+ * in a project on Bun — by `packageManager` of the github skill, otherwise Vitest — the spec skill's example), over the
1895
+ * roots the project has; next to it — `test` with the same roots excluded by the runner's filter, added to the command
1896
+ * under the wrapper (`unslot`): the roots run once, in `test:spec` (the spec skill, "Wiring into a repository").
1897
+ * `test` doesn't run them (names them already) or there is none — `test:spec` alone; `test` isn't a single runner call
1898
+ * — the exclusion in words.
1899
+ * With `test:spec` in place, a `test` that is a single runner call and doesn't name a root runs it a second time — an
1900
+ * `○` line with the same ready `test` (#487): extra time, not a hole in the check, so neither the exit code nor
1901
+ * `missing`. Any other `test` (turbo, a chain) — no line: its text doesn't tell what it runs. An exclusion in the
1902
+ * runner's config isn't seen in the text either — accepted noise: the ready line puts the exclusion in the script.
1903
+ */
1904
+ function specScript(root: string): { lines: string[]; missing: boolean } {
1905
+ const roots = specRoots(root);
1906
+ let pkg: { scripts?: Record<string, unknown>; dependencies?: object; devDependencies?: object } = {};
1907
+ try {
1908
+ pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) ?? {};
1909
+ } catch {
1910
+ // no package.json or not JSON — no scripts
1911
+ }
1912
+ if (!roots.length) return { lines: [], missing: false };
1913
+ const test = typeof pkg.scripts?.test === "string" ? unslot(pkg.scripts.test) : undefined;
1914
+ const pm = packageManager(root);
1915
+ const byTest = Object.values(RUNNERS).find((r) => test !== undefined && r.re.test(test));
1916
+ // the install wraps checks in the shim — a copy too, when update moves it
1917
+ const wrap = slotWrap(pm.berry);
1918
+ // the roots test runs too — they would run twice, in test and in test:spec
1919
+ const twice = test === undefined ? [] : roots.filter((dir) => !test.includes(dir));
1920
+ // a filter goes to a single runner call only: after `&&` or a pipe it would reach another command
1921
+ const filtered = test !== undefined && byTest && twice.length && !/[;&|<>`$()]/.test(test) ? `"test": ${JSON.stringify(wrap([test, ...twice.map(byTest.exclude)].join(" ")))}` : undefined;
1922
+ if (pkg.scripts?.["test:spec"]) return { lines: filtered ? [` ○ package.json: test repeats test:spec — runs ${twice.join(", ")} a second time; put in "scripts": ${filtered}`] : [], missing: false };
1923
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1924
+ const runner = byTest ?? ("vitest" in deps ? RUNNERS.vitest : "jest" in deps ? RUNNERS.jest : pm.name === "bun" ? RUNNERS.bun : RUNNERS.vitest);
1925
+ const spec = `"test:spec": ${JSON.stringify(wrap([runner.spec, ...roots].join(" ")))}`;
1926
+ const lead = ` ❌ package.json: no test:spec script — pr premerge doesn't run ${roots.join(", ")} on the merge with a moved main`;
1927
+ if (!twice.length) return { lines: [`${lead}; add to "scripts": ${spec}`], missing: true };
1928
+ if (!filtered) return { lines: [`${lead}; add to "scripts": ${spec}; exclude ${twice.join(", ")} from test with the runner's filter — they don't run twice`], missing: true };
1929
+ return { lines: [`${lead}; put in "scripts" — test without them, they don't run twice: ${spec}, ${filtered}`], missing: true };
1930
+ }
1931
+
1932
+ /** The check failed: what and why. */
1933
+ const unavailable = (global: boolean, e: unknown): Result => ({ code: 2, text: `Elves ${global ? "on the machine" : "in the project"}: check unavailable — ${reason(e)}` });
1934
+
1935
+ /**
1936
+ * The clone a `--link` install on the machine refers to: the path from the manifest; for an install of a previous
1937
+ * version — the symlink of the former canon in the home directory (FORMER.canon; `update -g` removes it and writes the
1938
+ * path). null — a copy or not installed.
1939
+ */
1940
+ function linkedClone(home: string) {
1941
+ const m = readManifest(home);
1942
+ if (typeof m.clone === "string") return m.clone;
1943
+ const canon = path.join(home, FORMER.canon);
1944
+ return lstat(canon)?.isSymbolicLink() ? path.resolve(path.dirname(canon), readlinkSync(canon)) : null;
1945
+ }
1946
+
1947
+ /** The install on the machine (global) or in the project: the root and whether the flow is there. */
1948
+ function target(global: boolean) {
1949
+ const home = os.homedir();
1950
+ const root = global ? home : projectRoot();
1951
+ // the home directory is the machine's install, not a project; an Elves clone is the canon itself: its manifest
1952
+ // keeps only the mode. An install of an earlier release — by the former names (FORMER)
1953
+ const installed = [CANON, MANIFEST, FORMER.canon, FORMER.manifest].some((p) => lstat(path.join(root, p))) && (global || (!samePath(root, home) && !isElvesClone(root)));
1954
+ return { home, root, installed };
1955
+ }
1956
+
1957
+ /** What a cloud session's environment needs (docs/cloud-sessions.md): Bun not older than the project's, zsh. */
1958
+ const CLOUD_BUN = "npm i -g bun@latest";
1959
+ // the image has no apt lists (2026-10-17: "Unable to locate package zsh") and its own /etc/zsh/zshrc: without
1960
+ // --force-confold dpkg asks about it and fails without a terminal
1961
+ const CLOUD_ZSH = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::=--force-confold zsh";
1962
+ /** A thin project's flow on the cloud session's machine — the npm package, the latest release. */
1963
+ const CLOUD_FLOW = `${CLOUD_BUN} ${PACKAGE} && elves install -g`;
1964
+
1965
+ /** A hook step: quiet; failed — a line with what failed and the command for the session, the hook goes on. */
1966
+ const hookStep = (cmd: string, what: string, shown = cmd) => `{ ${cmd}; } >/dev/null 2>&1 || echo "Elves cloud hook: ${what} (exit $?) — run: ${shown}"`;
1967
+
1968
+ /**
1969
+ * The cloud session's SessionStart hook in a project's `.claude/settings.json` (#610). The cloud runs the project's
1970
+ * hooks — not `~/.claude/settings.json` (#552) — after Claude Code starts, on every start and resume, and the hook does
1971
+ * what an environment's setup script did, without the owner's step: a fresh Bun (the image's lags behind the
1972
+ * project's `packageManager`, `bun upgrade` is 403 there), the flow onto the session's machine and `check --hook` into
1973
+ * the context — the machine's hook (HOOK) isn't there. Outside the cloud it exits at once: the machine's hook runs
1974
+ * there. The canon already on the machine — a setup script, an earlier start in the same VM — nothing to install. A
1975
+ * failed step — a line with its command; the code is always 0: Claude Code drops the output of any other.
1976
+ * An Elves clone (`clone`) installs the flow from itself, `install -g --link` — the canon of the branch, as a clone on
1977
+ * the owner's machine, and npm has no release yet then (only the `0.0.0` stub), — and zsh for its tests
1978
+ * (tests/capabilities/agent-shell).
1979
+ */
1980
+ function cloudHook(clone: boolean): SessionHook {
1981
+ const there = '[ -e "$HOME/.claude/rules/elves.md" ]';
1982
+ const elves = clone ? 'bun --no-env-file "$CLAUDE_PROJECT_DIR"/bin/elves.ts' : "elves";
1983
+ const steps = clone
1984
+ ? [
1985
+ `${there} || ${hookStep(CLOUD_BUN, "Bun isn't updated")}`,
1986
+ `command -v zsh >/dev/null || ${hookStep(CLOUD_ZSH, "no zsh for tests/capabilities/agent-shell")}`,
1987
+ hookStep(`${elves} install -g --link`, "the flow isn't installed", "bun --no-env-file bin/elves.ts install -g --link"),
1988
+ ]
1989
+ : [`${there} || { ${CLOUD_FLOW}; } >/dev/null 2>&1 || { echo "Elves cloud hook: the flow isn't installed (exit $?) — run: ${CLOUD_FLOW}"; exit 0; }`];
1990
+ const command = ['[ "$CLAUDE_CODE_REMOTE" = true ] || exit 0', ...steps, `${elves} check --hook 2>&1 || ${CHECK_FALLBACK}`].join("; ");
1991
+ return { matcher: HOOK_MATCHER, hooks: [{ type: "command", command, timeout: 120 }] };
1992
+ }
1993
+
1994
+ /** a < b for versions `1.4.2`: numeric parts, a pre-release suffix aside. */
1995
+ function olderVersion(a: string, b: string) {
1996
+ const [x, y] = [a, b].map((v) => (/^\d+(?:\.\d+)*/.exec(v)?.[0] ?? "").split(".").map(Number));
1997
+ for (let i = 0; i < Math.max(x!.length, y!.length); i++) if ((x![i] ?? 0) !== (y![i] ?? 0)) return (x![i] ?? 0) < (y![i] ?? 0);
1998
+ return false;
1999
+ }
2000
+
2001
+ /**
2002
+ * A Claude Code cloud session's environment (#598): the image's Bun lags behind the project's `packageManager` (1.4.2
2003
+ * without the `bun check` of 1.4.3: typecheck and tests are red), an Elves clone's tests need zsh
2004
+ * (tests/capabilities/agent-shell) the image doesn't have. The project's cloud hook installs them at the session's
2005
+ * start (`cloudHook`, #610); the line names an environment still without them — a project without the hook yet, a
2006
+ * failed step of the hook, a setup script's cache with an older Bun — with the command the session runs itself.
2007
+ * Doesn't change the check's code — the environment isn't the flow falling behind.
2008
+ */
2009
+ function cloudLines(root: string): string[] {
2010
+ if (!remote()) return [];
2011
+ const pm = packageManager(root);
2012
+ const want = pm.name === "bun" ? pm.version : null;
2013
+ const have = process.versions.bun;
2014
+ const missing: string[] = [];
2015
+ const fix: string[] = [];
2016
+ if (want && have && olderVersion(have, want)) missing.push(`Bun ${have} is older than packageManager bun@${want}`), fix.push(CLOUD_BUN);
2017
+ if (isElvesClone(root) && !onPath("zsh")) missing.push("no zsh for tests/capabilities/agent-shell"), fix.push(CLOUD_ZSH);
2018
+ if (!missing.length) return [];
2019
+ return [` cloud environment: ${missing.join("; ")} — the session runs it itself: ${fix.join(" && ")}; at a cloud session's start the project's SessionStart hook does it (docs/cloud-sessions.md)`];
2020
+ }
2021
+
2022
+ /**
2023
+ * Whether the install on the machine (global) or in the project is behind. This package (SRC, the release —
2024
+ * `toRelease`) checks a copy on the machine and a project with a dry-run install, a `--link` clone — against
2025
+ * `origin/main`. A thin project behind — update in the session's task: a few lines; a project with a copy — the move in
2026
+ * a task of its own (`updateTaskLine`).
2027
+ */
2028
+ function check(global: boolean): Result {
2029
+ const { home, root, installed } = target(global);
2030
+ const where = global ? "on the machine" : "in the project";
2031
+ // an Elves clone has nothing to compare against, but the session mode is needed in it — as in a project
2032
+ if (!installed && !global && isElvesClone(root)) return { code: 0, text: [`Elves ${where}: not installed — an Elves clone, the canon is at its root`, ...cloudLines(root), ...modeLines(root)].join("\n") };
2033
+ if (!installed) return { code: 0, text: `Elves ${where}: not installed`, absent: true };
2034
+ const clone = global ? linkedClone(home) : null;
2035
+ if (clone) return checkLink(home, clone);
2036
+ const copy = !global && isCopy(root);
2037
+ const changes = dryRun(() => (global ? installGlobal(SRC, false) : installProject(root)));
2038
+ const fresh = ownLabel();
2039
+ // mode and language are project settings: the last lines, the session start (hook) sees them too
2040
+ const mode = global ? [] : modeLines(root);
2041
+ // warnings — project settings that update doesn't fix: `!` lines, they don't change the exit code
2042
+ const warnings = changeLines(changes.filter((c) => c.op === "!"), root, global);
2043
+ // the compose file breaks task isolation — a mismatch (code 1) that update doesn't fix either; `○` — not checked
2044
+ const compose = global ? { lines: [], violations: false } : composeCheck(root);
2045
+ // standards without test:spec — a mismatch update doesn't add either
2046
+ const spec = global ? { lines: [], missing: false } : specScript(root);
2047
+ const project = [...warnings, ...compose.lines, ...spec.lines, ...(global ? [] : cloudLines(root)), ...mode];
2048
+ const stale = changes.filter((c) => c.op !== "❌" && c.op !== "!");
2049
+ const ignored = changes.filter((c) => c.op === "❌");
2050
+ if (!stale.length && !ignored.length) return { code: compose.violations || spec.missing ? 1 : 0, compose: compose.violations, spec: spec.missing, text: [`Elves ${where}: up to date (${fresh})`, ...project].join("\n") };
2051
+ const m = readManifest(root);
2052
+ const fixable = ignored.some((c) => !c.manual);
2053
+ const lost = "ignores flow files — they are missing from the commit, other checkouts and the cloud session:";
2054
+ const exceptions = fixable ? " — adds exceptions to .gitignore" : "";
2055
+ const installedAs = global || copy ? (m.tag ?? short(m.sha) ?? "without SHA") : shownPackage(projectPackage(root));
2056
+ const head = copy
2057
+ ? `Elves ${where}: a copy of the flow (installed ${installedAs}) — move it to a thin project: ${PACKAGE} ${ownVersion() ?? fresh} in devDependencies, the canon and the skills on the machine`
2058
+ : stale.length
2059
+ ? `Elves ${where}: behind — installed ${installedAs}, latest ${fresh}`
2060
+ : `Elves ${where}: up to date (${fresh}), but git ${lost}`;
2061
+ // update won't fix a personal rule — what to do is said in the ❌ line; a copy — in a task of its own, a thin project's
2062
+ // few lines — in the session's task
2063
+ const action = global
2064
+ ? [`${stale.length ? "Update" : "Fix"}: ${RUN} update -g${exceptions}`]
2065
+ : copy
2066
+ ? [`Move in a task of its own, not in this session's task: ${RUN} update${exceptions}`, updateTaskLine(root)]
2067
+ : [`${stale.length ? `Update in this session's task — ${PACKAGE} in package.json and the lockfile, a few lines` : "Fix in this session's task"}: ${RUN} update${exceptions}`];
2068
+ return {
2069
+ code: 1,
2070
+ behind: stale.length > 0,
2071
+ copy,
2072
+ ignored: ignored.length > 0,
2073
+ compose: compose.violations,
2074
+ spec: spec.missing,
2075
+ run: stale.length > 0 || fixable,
2076
+ text: [
2077
+ head,
2078
+ ...capped(changeLines(stale, root, global)),
2079
+ ...(stale.length && ignored.length ? [`Git ${lost}`] : []),
2080
+ ...capped(changeLines(ignored, root, global)),
2081
+ ...(stale.length || fixable ? action : []),
2082
+ ...project,
2083
+ ].join("\n"),
2084
+ };
2085
+ }
2086
+
2087
+ /**
2088
+ * A machine with `--link`: the clone against `origin/main` after `git fetch` — flow files the clone doesn't have yet
2089
+ * (the canon of the machine is the clone's: rules, references, skills), and missing links (a new skill, the rules, the
2090
+ * hook). Commits without flow files aren't falling behind.
2091
+ */
2092
+ function checkLink(home: string, clone: string): Result {
2093
+ const where = `on the machine (clone ${clone})`;
2094
+ let head, main, behind, files;
2095
+ try {
2096
+ git(clone, "fetch", "--quiet", "origin", "main");
2097
+ head = git(clone, "rev-parse", "HEAD");
2098
+ main = git(clone, "rev-parse", "origin/main");
2099
+ behind = Number(git(clone, "rev-list", "--count", "HEAD..origin/main"));
2100
+ files = git(clone, "diff", "--no-renames", "--name-status", "HEAD...origin/main", "--", ...FLOW).split("\n").filter(Boolean);
2101
+ } catch (e) {
2102
+ return { code: 2, text: `Elves ${where}: check unavailable — ${reason(e)}` };
2103
+ }
2104
+ const all = dryRun(() => installGlobal(clone, true));
2105
+ // warnings — what update doesn't fix: `!` lines, not falling behind
2106
+ const links = all.filter((c) => c.op !== "!");
2107
+ const warnings = changeLines(all.filter((c) => c.op === "!"), home, true);
2108
+ if (!files.length && !links.length) {
2109
+ const ahead = behind ? `; origin/main ${short(main)} is ahead by ${behind}, the flow isn't affected` : "";
2110
+ return { code: 0, text: [`Elves ${where}: up to date (${short(head)}${ahead})`, ...warnings, ...unreleased(clone)].join("\n") };
2111
+ }
2112
+ const op = (status: string) => (status === "A" ? "+" : status === "D" ? "-" : "~");
2113
+ return {
2114
+ code: 1,
2115
+ behind: true,
2116
+ text: [
2117
+ `Elves ${where}: behind — clone ${short(head)}, origin/main ${short(main)}`,
2118
+ ...capped([...files.map((l) => ` ${op(l.split("\t")[0] ?? "")} ${l.split("\t")[1]}`), ...changeLines(links, home, true)]),
2119
+ `Update: ${RUN} update -g`,
2120
+ ...warnings,
2121
+ ...unreleased(clone),
2122
+ ].join("\n"),
2123
+ };
2124
+ }
2125
+
2126
+ /**
2127
+ * A reminder to the owner on a machine with a `--link` clone: origin/main has flow changes that aren't in the latest
2128
+ * release — projects won't get them until it's cut. Only in an Elves clone's session: a release is the owner's step
2129
+ * there, in a project's session the line is noise at every start. Not falling behind: it doesn't change the check's
2130
+ * code. Can't tell — no line.
2131
+ */
2132
+ function unreleased(clone: string): string[] {
2133
+ if (!isElvesClone(projectRoot())) return [];
2134
+ const cmd = `release it: bun --no-env-file ${path.join(clone, "bin/elves.ts")} release`;
2135
+ try {
2136
+ const last = releases("origin", clone).at(-1);
2137
+ if (!last) return [` not in a release: no releases yet — ${cmd}`];
2138
+ const n = Number(git(clone, "rev-list", "--count", "--first-parent", `${last.sha}..origin/main`, "--", ...RELEASED));
2139
+ return n ? [` not in a release: flow commits after ${last.tag} — ${n}; ${cmd}`] : [];
2140
+ } catch {
2141
+ return [];
2142
+ }
2143
+ }
2144
+
2145
+ /**
2146
+ * The machine's proxy for task addresses (the task-env skill): one Traefik per machine gives containers of task stacks
2147
+ * the addresses `<service>.<N>.<project>.localhost` by their labels. Its Compose project is the skill's `proxyName`
2148
+ * (`$ELVES_PROXY`, by default `elves-proxy`). A line for `check -g` and the hook: running, not running — with the
2149
+ * start command, Docker unavailable — with the reason. No task-env skill or no docker on the machine — no line: without
2150
+ * Docker there are no task stacks, and a line in every session would be noise. Asks docker itself — the skill's
2151
+ * `proxyRunning` folds "docker not found" into its reason. Doesn't change the check's code — the proxy isn't the flow
2152
+ * falling behind. Not running, while the proxy of an earlier release under the flow's former name (`formerProxyName` of
2153
+ * the skill) is — it holds the port: the line names it, and `proxy up` of the skill replaces it.
2154
+ */
2155
+ function proxyLines(home: string): string[] {
2156
+ if (!existsSync(path.join(home, ".agents/skills/task-env/scripts/task-env.ts"))) return [];
2157
+ const name = proxyName();
2158
+ const ps = (project: string) =>
2159
+ spawnSync("docker", ["ps", "--all", "--filter", `label=com.docker.compose.project=${project}`, "--filter", "label=com.docker.compose.service=traefik", "--format", "{{.State}}"], { encoding: "utf8", timeout: 10_000 });
2160
+ const r = ps(name);
2161
+ const err = r.error as NodeJS.ErrnoException | undefined;
2162
+ if (err?.code === "ENOENT") return [];
2163
+ if (err || r.status !== 0) return [` proxy *.localhost: Docker unavailable — ${err ? (err.code === "ETIMEDOUT" ? "docker didn't answer in 10 s" : err.message) : String(r.stderr).trim().split("\n")[0]}`];
2164
+ const running = (x: ReturnType<typeof ps>) => !x.error && x.status === 0 && x.stdout.split("\n").includes("running");
2165
+ if (running(r)) return [` proxy *.localhost: running (${name})`];
2166
+ const up = "bun --no-env-file ~/.agents/skills/task-env/scripts/task-env.ts proxy up";
2167
+ const former = formerProxyName();
2168
+ if (running(ps(former))) return [` proxy *.localhost: running under the flow's former name (${former}) — replace it: ${up}`];
2169
+ return [` proxy *.localhost: not running — start: ${up}`];
2170
+ }
2171
+
2172
+ /**
2173
+ * A user's variables under the flow's former name (FORMER.env) — set where `update` doesn't reach (the shell profile):
2174
+ * a line each with the new name. The skills' scripts read them until renamed.
2175
+ */
2176
+ function formerEnvLines(): string[] {
2177
+ return Object.entries(FORMER.env)
2178
+ .filter(([, former]) => process.env[former])
2179
+ .map(([name, former]) => ` ${former}: the flow's former name — rename it to ${name} where it is set (the shell profile)`);
2180
+ }
2181
+
2182
+ /**
2183
+ * The agent's Bash environment (tests/capabilities/agent-shell). Claude Code's Bash tool — the user's zsh on macOS —
2184
+ * puts the text of the files SessionStart hooks write to `$CLAUDE_ENV_FILE` into every call, after the user's shell
2185
+ * snapshot and before the command's `eval` (checked on Claude Code 2.1.295): shell options and PATH from there hold
2186
+ * in the command itself, in subagents too. zsh breaks commands written for bash: a glob without matches is an error
2187
+ * (`no matches found: --include=*.ts`, and grep in a pipe reads it as "nothing found"), a word starting with `=` is a
2188
+ * command's path (`echo ====`, `[ a == b ]`), and macOS has no `timeout`. So: NO_NOMATCH and NO_EQUALS in zsh, this
2189
+ * package's `bin/shims` (`timeout`) at the end of PATH — a real `timeout` wins — and the AGENT_SHELL mark `check -g`
2190
+ * recognises the environment by.
2191
+ */
2192
+ const AGENT_SHELL = "ELVES_AGENT_SHELL";
2193
+
2194
+ /** A word for sh: in single quotes. */
2195
+ const shQuote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`;
2196
+
2197
+ function agentShellScript() {
2198
+ return [
2199
+ `# Elves: the agent's Bash environment, written by the SessionStart hook (${RUN} check --hook)`,
2200
+ `export ${AGENT_SHELL}=1`,
2201
+ 'if [ -n "${ZSH_VERSION-}" ]; then setopt NO_NOMATCH NO_EQUALS; fi',
2202
+ `export PATH="\${PATH:+$PATH:}"${shQuote(path.join(SRC, "bin/shims"))}`,
2203
+ "",
2204
+ ].join("\n");
2205
+ }
2206
+
2207
+ /** Without the environment the zsh pitfalls are back: what the agent does by hand meanwhile. */
2208
+ const SHELL_FALLBACK = "meanwhile in zsh quote globs ('*.ts') and words starting with = ('===='), use the Bash tool's timeout instead of timeout";
2209
+
2210
+ /**
2211
+ * The SessionStart hook: the agent's Bash environment into `$CLAUDE_ENV_FILE` — the file Claude Code gives this hook
2212
+ * (`~/.claude/session-env/<session>/sessionstart-hook-<N>.sh`); rewritten on every start, resume and clear, a repeat
2213
+ * doesn't add a second copy. Written — no line: the hook's output is read in every session.
2214
+ */
2215
+ function writeAgentShell(): string[] {
2216
+ const file = process.env.CLAUDE_ENV_FILE;
2217
+ if (!file) return [` agent Bash: not set up — Claude Code gave the hook no CLAUDE_ENV_FILE; ${SHELL_FALLBACK}`];
2218
+ try {
2219
+ mkdirSync(path.dirname(file), { recursive: true });
2220
+ writeFileSync(file, agentShellScript());
2221
+ return [];
2222
+ } catch (e) {
2223
+ return [` agent Bash: not set up — can't write CLAUDE_ENV_FILE ${file}: ${reason(e)}; ${SHELL_FALLBACK}`];
2224
+ }
2225
+ }
2226
+
2227
+ /**
2228
+ * `check -g` from the agent's Bash (Claude Code sets CLAUDECODE=1 in it): no AGENT_SHELL mark — the session didn't get
2229
+ * the environment. From a terminal the agent's shell can't be seen — no line. A session started before `update -g`
2230
+ * has the mark of the hook of an earlier release (FORMER.agentShell) — the environment is there.
2231
+ */
2232
+ function agentShellLines(): string[] {
2233
+ if (process.env.CLAUDECODE !== "1" || process.env[AGENT_SHELL] || process.env[FORMER.agentShell]) return [];
2234
+ return [` agent Bash: not set up — this session got no environment from the Elves SessionStart hook (its output at the session start says why); ${SHELL_FALLBACK}`];
2235
+ }
2236
+
2237
+ /**
2238
+ * The check's result plus the machine's lines, installed or not, behind or not: the proxy (`proxyLines`), the agent's
2239
+ * Bash environment — the hook passes what writing it gave, `check -g` asks `agentShellLines` — and the user's variables
2240
+ * under the flow's former name (`formerEnvLines`).
2241
+ */
2242
+ function withMachine(global: boolean, r: Result, shell = agentShellLines()): Result {
2243
+ if (!global || r.absent) return r;
2244
+ const lines = [...proxyLines(os.homedir()), ...shell, ...formerEnvLines()];
2245
+ return lines.length ? { ...r, text: [r.text, ...lines].join("\n") } : r;
2246
+ }
2247
+
2248
+ /**
2249
+ * The hook runs outside the Bash sandbox and doesn't see it, but the agent runs the commands it names inside: bunx
2250
+ * there can't write its cache `~/.bun/install/cache` ("unable to write files to tempdir: EPERM"), the install —
2251
+ * `.claude/skills` (`denied`).
2252
+ */
2253
+ const SANDBOX_HINT = `In the Claude Code sandbox bunx can't write its cache ~/.bun/install/cache — prefix the command with BUN_INSTALL_CACHE_DIR="$TMPDIR/bun"; the sandbox refuses writes to .claude/ too — then the owner runs the command in their terminal.`;
2254
+
2255
+ /** One request or two of the github skill to GitHub; the hook has 60 s for everything. */
2256
+ const RESUME_TIMEOUT = 20_000;
2257
+
2258
+ /**
2259
+ * The session id from the hook's input: Claude Code passes JSON on stdin (`session_id`, `source` — startup, resume,
2260
+ * clear); a run by hand in a terminal has no input — then `CLAUDE_CODE_SESSION_ID`, as the agent's Bash has it.
2261
+ */
2262
+ function hookSession(): string | null {
2263
+ let id: unknown;
2264
+ if (!isatty(0)) {
2265
+ try {
2266
+ id = JSON.parse(readFileSync(0, "utf8")).session_id;
2267
+ } catch {
2268
+ /* no input or not JSON */
2269
+ }
2270
+ }
2271
+ return (typeof id === "string" && id) || process.env.CLAUDE_CODE_SESSION_ID || null;
2272
+ }
2273
+
2274
+ /**
2275
+ * The github skill script the agent runs: the machine's — a same-named machine skill wins over the project's in Claude
2276
+ * Code, — otherwise the project's copy, otherwise this package's. The commands it prints name its own paths.
2277
+ * withProject false — without the project's copy: a command for updating it (updateTaskLine) when it's behind.
2278
+ */
2279
+ function githubScript(withProject = true): string {
2280
+ const rel = "skills/github/scripts/github.ts";
2281
+ const roots = [path.join(os.homedir(), ".agents"), ...(withProject ? [path.join(projectRoot(), ".agents")] : []), SRC];
2282
+ return roots.map((r) => path.join(r, rel)).find((f) => existsSync(f)) ?? path.join(SRC, rel);
2283
+ }
2284
+
2285
+ /**
2286
+ * A background wait (ci-wait) dies with the app's process, and the session resumed after a restart doesn't know it has
2287
+ * to restart it (#433). The task pinned to the session and its PR are known to the github skill: `task resume` names
2288
+ * the wait; no pin — it prints nothing. Not filtered by `source`: the pin itself is the sign of a resumed session — a
2289
+ * new session and /clear have a new id. A failure — its lines and a `!` line: the hook's code stays 0.
2290
+ */
2291
+ function resumeLines(): string[] {
2292
+ const id = hookSession();
2293
+ if (!id) return [];
2294
+ const r = spawnSync(process.execPath, ["--no-env-file", githubScript(), "task", "resume"], { encoding: "utf8", timeout: RESUME_TIMEOUT, env: { ...process.env, CLAUDE_CODE_SESSION_ID: id } });
2295
+ const lines = (r.stdout ?? "").split("\n").filter((l) => l.trim());
2296
+ if (!r.error && r.status === 0) return lines;
2297
+ const why = r.error ? r.error.message : String(r.stderr).trim().split("\n").filter(Boolean).pop() || `exit code ${r.status ?? r.signal}`;
2298
+ return [...lines, `! the session's task — github task resume: ${why}`];
2299
+ }
2300
+
2301
+ /**
2302
+ * The canon loads twice (#557): Claude Code reads the machine's rules (`~/.claude/rules/elves*.md`, `install -g`) and
2303
+ * the project's copy (`.claude/rules/elves*.md`, under the flow's former name too) — both, on every turn. Allowed for
2304
+ * the move (#180 removed the machine's rules only because of that copy, and projects with a copy are few): the hook
2305
+ * names it and the move — the project's update task, `update` turns the copy into the thin project. A rule that doesn't
2306
+ * load (a dangling symlink) doesn't count; an Elves clone has no copy, the home directory isn't a project.
2307
+ */
2308
+ function twiceLines(home: string, root: string): string[] {
2309
+ const loads = (p: string) => {
2310
+ try {
2311
+ return statSync(p).isFile();
2312
+ } catch {
2313
+ return false;
2314
+ }
2315
+ };
2316
+ if (samePath(root, home) || isElvesClone(root)) return [];
2317
+ const machine = Object.values(RULES).filter((rel) => loads(path.join(home, rel)));
2318
+ const project = [...Object.values(RULES), ...FORMER.rules].filter((rel) => loads(path.join(root, rel)));
2319
+ if (!machine.length || !project.length) return [];
2320
+ return [` canon loaded twice: the machine's rules ${machine.map((r) => `~/${r}`).join(", ")} and the project's copy ${project.join(", ")} — Claude Code reads both on every turn until the project moves to the thin project: its update, in a task of its own`];
2321
+ }
2322
+
2323
+ /**
2324
+ * The SessionStart hook: the agent's Bash environment, then the machine and the project in one output to the session
2325
+ * context, then the wait a resumed session restarts (not in the cloud: no Projects there); the code is always 0, errors
2326
+ * go to stdout. failed — the latest release couldn't be found: what's installed is "check unavailable", the environment
2327
+ * is written all the same.
2328
+ */
2329
+ function hook(failed?: unknown) {
2330
+ const shell = writeAgentShell();
2331
+ const results: Result[] = [];
2332
+ for (const global of [true, false]) {
2333
+ try {
2334
+ results.push(withMachine(global, failed && target(global).installed ? unavailable(global, failed) : check(global), shell));
2335
+ } catch (e) {
2336
+ results.push(withMachine(global, unavailable(global, e), shell));
2337
+ }
2338
+ }
2339
+ const [machine, project] = results;
2340
+ const shown = results.filter((r) => !r.absent);
2341
+ // a git repository without the flow — how to install it (an Elves clone is the canon itself, it isn't absent)
2342
+ if (project?.absent && gitRoot()) shown.push({ code: 0, run: true, text: `Elves in the project: not installed — install it: ${RUN} install --lang <project language>` });
2343
+ // the canon twice: the update task's line — once, the project's behind text may have printed it already
2344
+ const root = projectRoot();
2345
+ const twice = twiceLines(os.homedir(), root);
2346
+ if (twice.length) shown.push({ code: 0, text: [...twice, ...(project?.run ? [] : [updateTaskLine(root)])].join("\n") });
2347
+ // Projects are closed to a cloud session (403): task resume refuses there — a `!` line at every start
2348
+ const resume = remote() ? [] : resumeLines();
2349
+ if (!shown.length && !resume.length) return 0;
2350
+ const tail = [];
2351
+ // the machine's update touches only ~/.agents and the hook — the session does it; a thin project's — a few lines in
2352
+ // the branch of the session's task; a copy's move — a task of its own (updateTaskLine), the session works by the
2353
+ // current flow
2354
+ if (machine?.behind) tail.push("The machine is behind — update -g, tell the user what changed (update lists it), re-read what was updated.");
2355
+ if (project?.copy) {
2356
+ tail.push(
2357
+ "The project keeps a copy of the flow — moving it to a thin project is a task of its own, not in a task session: recommend it to the user and ask whether to queue its task (task new above): yes — a background executor (the github skill, «Dispatcher»); work by the current flow. The move task — update, tell the user what changed, fix what it names, run the project's checks, commit into its branch.",
2358
+ );
2359
+ } else if (project?.behind) {
2360
+ tail.push("The project's flow is behind — update in the session's task: it bumps the package version in package.json and the lockfile; tell the user what changed, commit it with the task.");
2361
+ }
2362
+ if (twice.length) {
2363
+ tail.push("The canon loads twice — the machine's rules and the project's copy: recommend the project's move to the thin project and ask whether to queue its update task (task new above); work by the current flow.");
2364
+ }
2365
+ if (shown.some((r) => r.ignored)) tail.push("Git ignores flow files — update adds exceptions to .gitignore, committed with the update; a rule outside the project — fix it yourself.");
2366
+ // the session has no task yet at its start, and one session is one task: the fix goes into a task of its own
2367
+ if (shown.some((r) => r.compose)) tail.push("The compose file breaks task isolation — each ❌ line names the rule and the service (the task-env skill); fixing it is a task of its own in the project.");
2368
+ if (shown.some((r) => r.spec)) tail.push("No test:spec script — pr premerge doesn't run standards and architecture on the merge; the ❌ line names the package.json scripts; adding them is a task of its own in the project.");
2369
+ if (shown.some((r) => r.code === 2)) tail.push("Check unavailable — work by the current flow and say so.");
2370
+ if (shown.some((r) => r.run)) tail.push(SANDBOX_HINT);
2371
+ note(["Elves flow (AGENTS.md, «First step of a session»):", ...shown.map((r) => r.text), ...resume, ...tail].join("\n"));
2372
+ return 0;
2373
+ }
2374
+
2375
+ /**
2376
+ * The flow copy's commit subject — in the project language, the `updateCommit` key of this package's github skill
2377
+ * dictionary: "Elves flow <tag>" in English, the same phrase translated in other languages.
2378
+ */
2379
+ const commitSubject = (root: string, version: string | null | undefined) => locale.fill(locale.names(languageOf(root)).updateCommit, { version: version ?? "" }).trim();
2380
+
2381
+ /**
2382
+ * Where a thin project's update starts: the release of its package version and the release's SHA from the tags
2383
+ * (`releases`); `why` — the reason the SHA is unknown, for `whatChanged`.
2384
+ */
2385
+ function packageFrom(root: string): { from: { sha?: string; tag?: string }; why: string } {
2386
+ const spec = projectPackage(root);
2387
+ const tag = spec === undefined ? null : releaseTag(spec);
2388
+ if (!tag) return { from: { tag: shownPackage(spec) }, why: spec === undefined ? `the project had no ${PACKAGE}` : `${PACKAGE} ${spec} isn't a release version` };
2389
+ try {
2390
+ const sha = releases(repoUrl()).find((r) => r.tag === tag)?.sha;
2391
+ return { from: { tag, ...(sha ? { sha } : {}) }, why: `no release ${tag} among the tags` };
2392
+ } catch (e) {
2393
+ return { from: { tag }, why: reason(e) };
2394
+ }
2395
+ }
2396
+
2397
+ /** A path for sh: as is, or in single quotes. */
2398
+ const shWord = (s: string) => (/^[\w./@:+-]+$/.test(s) ? s : shQuote(s));
2399
+
2400
+ /**
2401
+ * What goes into the commit of an update: what the install changed and the flow files git doesn't track yet (ignored
2402
+ * before the update added exceptions) — of them what exists now or what git tracks: `git add` of a removed path git
2403
+ * never tracked fails on the pathspec, and the commit with it.
2404
+ */
2405
+ function committable(root: string, changed: string[]) {
2406
+ const tracked = (rel: string) => {
2407
+ try {
2408
+ return git(root, "ls-files", "--", rel) !== "";
2409
+ } catch {
2410
+ return false;
2411
+ }
2412
+ };
2413
+ const flow = flowEntries(root).map((e) => e.path).filter((rel) => !tracked(rel));
2414
+ return [...new Set([...changed, ...flow])].filter((rel) => lstat(path.join(root, rel)) || tracked(rel));
2415
+ }
2416
+
2417
+ /**
2418
+ * Bring the install up to date. A machine with `--link` — `updateLink`; the machine's copy — `install` by this
2419
+ * package; a project — `installProject` with what changed and the commit of what it wrote: a thin project's — in the
2420
+ * session's task, a copy's move — in the update task's branch.
2421
+ */
2422
+ function update(global: boolean) {
2423
+ if (global) {
2424
+ const home = os.homedir();
2425
+ const clone = linkedClone(home);
2426
+ if (clone) return updateLink(clone);
2427
+ const from = readManifest(home);
2428
+ installGlobal(SRC, false);
2429
+ note(whatChanged(from).join("\n"));
2430
+ return 0;
2431
+ }
2432
+ const root = projectRoot();
2433
+ if (isElvesClone(root)) return note(CLONE_NOTE), 0;
2434
+ const copy = isCopy(root);
2435
+ const { from, why } = copy ? { from: readManifest(root), why: undefined } : packageFrom(root);
2436
+ installProject(root);
2437
+ const version = ownLabel();
2438
+ const add = committable(root, touched);
2439
+ note(
2440
+ [
2441
+ ...whatChanged(from, why),
2442
+ ...(add.length
2443
+ ? [
2444
+ copy ? "Commit the move into the update task's branch:" : "Commit it with the session's task:",
2445
+ ` git add -A -- ${add.map(shWord).join(" ")} && git commit -m "chore(agents): ${commitSubject(root, version)}"`,
2446
+ ]
2447
+ : []),
2448
+ ].join("\n"),
2449
+ );
2450
+ return 0;
2451
+ }
2452
+
2453
+ /**
2454
+ * What the update brought, for the agent to tell the user: every release after the installed one up to this package's,
2455
+ * newest first — the tag, its page and the list its GitHub Release has (`changeItems`); no releases yet or an installer
2456
+ * from a clone — also the flow commits after the last release up to this package's SHA. Can't tell (an install without
2457
+ * SHA, no network, the SHA isn't in the history) — a line with the reason: the flow is already installed, the update
2458
+ * doesn't fail over it.
2459
+ * @param from the manifest before the update; a thin project — the release of its package version (`packageFrom`)
2460
+ * @param why why `from` has no SHA
2461
+ */
2462
+ function whatChanged(from: { sha?: unknown; tag?: unknown }, why = "installed without SHA"): string[] {
2463
+ const to = sourceSha(SRC);
2464
+ const sha = typeof from.sha === "string" ? from.sha : null;
2465
+ const was = (typeof from.tag === "string" && from.tag) || short(sha) || "without SHA";
2466
+ const now = ownLabel();
2467
+ if (sha && sha === to) return [`Elves ${now} was already installed — nothing changed`];
2468
+ const unavailable = (why: string) => [`Elves ${was} → ${now}: changes unavailable — ${why}; releases: ${RELEASES}`];
2469
+ if (!sha) return unavailable(why);
2470
+ if (!to) return unavailable("the SHA of this package is unknown");
2471
+ let groups;
2472
+ try {
2473
+ groups = releaseGroups(sha, to);
2474
+ } catch (e) {
2475
+ return unavailable(reason(e));
2476
+ }
2477
+ const tags = groups.filter((g) => g.tag).length;
2478
+ return [
2479
+ `Elves ${was} → ${now}${tags ? `, new releases: ${tags}` : ""} — tell the user what changed:`,
2480
+ // commits after the last release (an installer from a clone) are named only next to releases
2481
+ ...groups.flatMap((g) => [...(g.tag ? [`${g.tag} — ${RELEASES}/tag/${g.tag}`] : tags ? [`${short(to)} — not in a release`] : []), ...g.items.map((i) => ` ${i}`)]),
2482
+ ];
2483
+ }
2484
+
2485
+ /**
2486
+ * The releases between two SHAs of Elves, newest first, each with its list of changes; commits after the last of them
2487
+ * (no releases yet, an installer from a clone) — a group without a tag. The history comes from where the releases do,
2488
+ * fetched without file contents into a temporary repository: the bunx package has no `.git`. A release's list starts
2489
+ * from the previous release, the first one's — from `from`: with an install from a release that is exactly its GitHub
2490
+ * Release's list.
2491
+ */
2492
+ function releaseGroups(from: string, to: string): { tag?: string; items: string[] }[] {
2493
+ const remote = repoUrl();
2494
+ const tmp = mkdtempSync(path.join(os.tmpdir(), "elves-history-"));
2495
+ try {
2496
+ git(tmp, "init", "--quiet", "--bare");
2497
+ git(tmp, "fetch", "--quiet", "--no-tags", "--filter=blob:none", remote, to);
2498
+ const range = new Set(git(tmp, "rev-list", `${from}..${to}`).split("\n"));
2499
+ const tags = releases(remote).filter((r) => range.has(r.sha));
2500
+ const groups: { tag?: string; items: string[] }[] = [];
2501
+ let prev = from;
2502
+ for (const r of tags) groups.push({ tag: r.tag, items: changeItems(tmp, `${prev}..${(prev = r.sha)}`) });
2503
+ if (prev !== to) groups.push({ items: changeItems(tmp, `${prev}..${to}`) });
2504
+ return groups.reverse();
2505
+ } finally {
2506
+ rmSync(tmp, { recursive: true, force: true });
2507
+ }
2508
+ }
2509
+
2510
+ /**
2511
+ * The flow changes in a range of first-parent commits, a `- …` line each — the list of a GitHub Release and of
2512
+ * `update`: commits that touched the flow or the installer; a PR merged with a merge commit — by its title and number,
2513
+ * one merged by rebase — by its own commits with the task number from `Refs #N`.
2514
+ */
2515
+ function changeItems(repo: string, range: string): string[] {
2516
+ return git(repo, "log", "--first-parent", "--format=%s%x1f%b%x1e", range, "--", ...RELEASED)
2517
+ .split("\x1e")
2518
+ .map((entry) => entry.trim())
2519
+ .filter(Boolean)
2520
+ .map((entry) => {
2521
+ const [subject = "", body = ""] = entry.split("\x1f");
2522
+ const pr = /^Merge pull request #(\d+) /.exec(subject);
2523
+ const title = body.trim().split("\n")[0];
2524
+ if (pr && title) return `- ${title} (#${pr[1]})`;
2525
+ const refs = [...new Set([...body.matchAll(/^Refs #(\d+)/gm)].map((m) => `#${m[1]}`))];
2526
+ return refs.length ? `- ${subject} (${refs.join(", ")})` : `- ${subject}`;
2527
+ });
2528
+ }
2529
+
2530
+ /**
2531
+ * A `--link` clone: only main and without changes in tracked files — otherwise a refusal (code 1), the clone is left
2532
+ * alone: that's the user's work. Then `git pull --ff-only` and `install -g --link` with the code of the updated clone:
2533
+ * this process may have loaded its old version, and new skills need links too.
2534
+ */
2535
+ function updateLink(clone: string) {
2536
+ let branch = "";
2537
+ try {
2538
+ branch = git(clone, "symbolic-ref", "--quiet", "--short", "HEAD");
2539
+ } catch {
2540
+ /* HEAD is detached */
2541
+ }
2542
+ if (branch !== "main") return warn(`update: clone ${clone} is not on main (${branch ? `branch ${branch}` : "HEAD detached"}) — switch it yourself and retry`), 1;
2543
+ const dirty = execFileSync("git", ["-C", clone, "status", "--porcelain", "--untracked-files=no"], { encoding: "utf8" }).trimEnd();
2544
+ if (dirty) return warn(`update: clone ${clone} has changes in tracked files — commit or remove them yourself and retry:\n${dirty}`), 1;
2545
+ const before = git(clone, "rev-parse", "HEAD");
2546
+ try {
2547
+ git(clone, "pull", "--ff-only", "--quiet", "origin", "main");
2548
+ } catch (e) {
2549
+ return warn(`update: git pull --ff-only in clone ${clone} — ${reason(e)}`), 2;
2550
+ }
2551
+ const after = git(clone, "rev-parse", "HEAD");
2552
+ const items = changeItems(clone, `${before}..${after}`);
2553
+ const head = `clone ${clone}: ${short(before)} → ${short(after)}`;
2554
+ note((items.length ? [`${head} — tell the user what changed:`, ...items.map((i) => ` ${i}`)] : [`${head}, the flow hasn't changed`]).join("\n"));
2555
+ return spawnSync(process.execPath, ["--no-env-file", path.join(clone, "bin/elves.ts"), "install", "-g", "--link"], { stdio: "inherit" }).status ?? 2;
2556
+ }
2557
+
2558
+ /**
2559
+ * A release is the owner's step, from a clone: the tag vYYYY.MM.DD (the date in UTC; a second one the same day — .1,
2560
+ * .2…) on origin/main and a GitHub Release with the list of changes since the previous release (`changeItems` — `update`
2561
+ * prints the same list). Nothing to release — a refusal (code 1): projects mustn't update for nothing.
2562
+ * @param preview `--dry-run`: show the tag and the list, create nothing
2563
+ */
2564
+ function releaseCmd(preview: boolean) {
2565
+ if (!existsSync(path.join(SRC, ".git"))) return warn("release — only from an Elves clone: bun <clone>/bin/elves.ts release"), 2;
2566
+ let head, tags, items;
2567
+ try {
2568
+ git(SRC, "fetch", "--quiet", "origin", "main");
2569
+ head = git(SRC, "rev-parse", "origin/main");
2570
+ tags = releases("origin", SRC);
2571
+ const last = tags.at(-1);
2572
+ items = last ? changeItems(SRC, `${last.sha}..${head}`) : [];
2573
+ } catch (e) {
2574
+ return warn(`release: ${reason(e)}`), 2;
2575
+ }
2576
+ const last = tags.at(-1);
2577
+ if (last?.sha === head) return warn(`release: origin/main ${short(head)} is already in release ${last.tag}`), 1;
2578
+ if (last && !items.length) return warn(`release: the flow hasn't changed since ${last.tag} — no release needed`), 1;
2579
+ const notes = last ? [`Flow changes since ${last.tag}:`, "", ...items].join("\n") : "First release: projects install and update the flow by Elves releases, not by main.";
2580
+ const day = `v${new Date().toISOString().slice(0, 10).replaceAll("-", ".")}`;
2581
+ const names = new Set(tags.map((t) => t.tag));
2582
+ let tag = day;
2583
+ for (let n = 1; names.has(tag); n++) tag = `${day}.${n}`;
2584
+ note(`${tag} → ${short(head)}\n\n${notes}`);
2585
+ if (preview) return 0;
2586
+ try {
2587
+ execFileSync("gh", ["release", "create", tag, "--repo", SOURCE, "--target", head, "--title", tag, "--notes", notes], { stdio: ["ignore", "pipe", "pipe"] });
2588
+ } catch (e) {
2589
+ return warn(`release: gh release create — ${reason(e)}`), 2;
2590
+ }
2591
+ note(`\nRelease ${tag}: ${RELEASES}/tag/${tag}`);
2592
+ return 0;
2593
+ }
2594
+
2595
+ /**
2596
+ * The npm version of a release tag: vYYYY.MM.DD.N → YYYY.MMDD.N, without a patch — .0. Semver has no leading zeros, and
2597
+ * a same-day patch needs a number of its own: a prerelease (`-1`) sorts below the release itself, build metadata (`+1`)
2598
+ * npm doesn't tell apart. MMDD as a number grows with the date — versions sort as tags. Not a release tag — null.
2599
+ */
2600
+ function npmVersion(tag: string): string | null {
2601
+ const m = RELEASE_TAG.exec(tag);
2602
+ return m ? `${Number(m[1])}.${Number(m[2]) * 100 + Number(m[3])}.${Number(m[4] ?? 0)}` : null;
2603
+ }
2604
+
2605
+ /**
2606
+ * A CommonJS module that names its own file (`__filename`: the knip reporter's path for `deadCode`) — the bundler would
2607
+ * bake the build machine's path into it, and the package would lead into nowhere. It stays a file of its own
2608
+ * (`--external`), copied next to the chunks that import it: the chunks go to CHUNKS beside it, the import keeps its
2609
+ * `./` path.
2610
+ */
2611
+ const OWN_FILE = ["skills/spec/scripts/knip-hints.cjs"];
2612
+ /** Where the build's chunks go, from dist/: next to OWN_FILE. */
2613
+ const CHUNKS = "spec/scripts";
2614
+
2615
+ /** The modules built for Node: each export of package.json whose `default` is the build of its `bun` source. */
2616
+ function builtExports(pkg: { exports?: unknown }): { src: string; out: string }[] {
2617
+ const entries = pkg.exports && typeof pkg.exports === "object" ? Object.values(pkg.exports) : [];
2618
+ return entries.flatMap((e) => (e && typeof e === "object" && "bun" in e && "default" in e ? [{ src: String(e.bun), out: String(e.default) }] : []));
2619
+ }
2620
+
2621
+ /**
2622
+ * `pack`, CI on a release tag: the npm package of the release — the version from the tag into package.json, the release
2623
+ * commit into its `gitHead` (the package knows its SHA without the network: the manifest of an install from it and
2624
+ * what `update` lists — `sourceSha`) and the modules a project imports (`builtExports`) built into dist/. Node doesn't
2625
+ * strip types in node_modules
2626
+ * (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING): a project's `eslint.config.js`, `node --test`, Playwright and Jest load
2627
+ * the JS (`default`), Bun and the type checker — the TypeScript source (`bun`, `types`). One build with shared chunks:
2628
+ * the modules share the language module and the TypeScript parser. A built module is a project's import, never the entry
2629
+ * point: `import.meta.main` is false — Bun's build for Node turns it into `require.main == require.module`, true under
2630
+ * Node's ESM, and `github` would run its command line on import. The build runs on CI and works at a project: a path of
2631
+ * the build machine in it (OWN_FILE) is a refusal. `npm publish` is the workflow's next step. The build fails —
2632
+ * package.json stays as it was.
2633
+ */
2634
+ function pack(tag: string | undefined): number {
2635
+ const version = tag === undefined ? null : npmVersion(tag);
2636
+ if (!version) return warn(`pack: a release tag is needed — vYYYY.MM.DD or vYYYY.MM.DD.N, not «${tag ?? ""}»`), 2;
2637
+ if (!existsSync(path.join(SRC, ".git"))) return warn("pack — only from an Elves checkout: bun <checkout>/bin/elves.ts pack <tag>"), 2;
2638
+ const file = path.join(SRC, "package.json");
2639
+ const pkg = JSON.parse(readFileSync(file, "utf8"));
2640
+ const entries = builtExports(pkg);
2641
+ const skills = path.join(SRC, "skills");
2642
+ const dist = path.join(SRC, "dist");
2643
+ rmSync(dist, { recursive: true, force: true });
2644
+ const own = OWN_FILE.map((rel) => ({ from: path.join(SRC, rel), to: path.join(dist, path.relative(skills, path.join(SRC, rel))) }));
2645
+ const args = ["--target=node", "--format=esm", "--splitting", "--define", "import.meta.main=false", `--root=${skills}`, `--outdir=${dist}`, `--chunk-naming=${CHUNKS}/[name]-[hash].[ext]`, ...own.flatMap((o) => ["--external", `*${path.basename(o.from)}`])];
2646
+ const r = spawnSync(process.execPath, ["--no-env-file", "build", ...args, ...entries.map((e) => path.join(SRC, e.src))], { encoding: "utf8" });
2647
+ if (r.error || r.status !== 0) return warn(`pack: bun build — ${r.error?.message ?? (r.stderr || r.stdout).trim().split("\n").slice(-3).join("; ")}`), 2;
2648
+ for (const o of own) cpSync(o.from, o.to);
2649
+ const missing = entries.filter((e) => !existsSync(path.join(SRC, e.out)));
2650
+ if (missing.length) return warn(`pack: the build has no ${missing.map((e) => e.out).join(", ")} — exports of package.json lead past it`), 2;
2651
+ const baked = [...walk(dist).entries()].filter(([, file]) => readFileSync(file, "utf8").includes(SRC)).map(([rel]) => `dist/${rel}`);
2652
+ if (baked.length) return warn(`pack: ${baked.join(", ")} — the build machine's path ${SRC} is baked in: the package would lead into nowhere`), 2;
2653
+ writeFileSync(file, JSON.stringify({ ...pkg, version, gitHead: git(SRC, "rev-parse", "HEAD") }, null, 2) + "\n");
2654
+ note(`${PACKAGE}@${version} — release ${tag}: dist/ ← ${entries.map((e) => e.src).join(", ")}; next: npm publish`);
2655
+ return 0;
2656
+ }
2657
+
2658
+ /**
2659
+ * The `install` question in a project about the `auto` setting: "yes" allows the agent everything listed without a
2660
+ * "yes" for each action.
2661
+ */
2662
+ const AUTO_QUESTION = "Let the agent do tasks entirely by itself — answers by recommendation, commit, push, fixing CI, merge on green checks?";
2663
+
2664
+ /**
2665
+ * The `install` question in a project about the language: in it the agent talks to the human and writes tasks,
2666
+ * prompts and documents.
2667
+ */
2668
+ const LANGUAGE_QUESTION = "Project language the agent works in — dialog, tasks, prompts, documents (code: ru, en, …)?";
2669
+
2670
+ /**
2671
+ * The answer to a question in the terminal: the line trimmed of spaces; input closed or unreadable — null.
2672
+ * Synchronous and without dependencies — `readSync(0)`: `main` is synchronous.
2673
+ */
2674
+ function readAnswer(prompt: string) {
2675
+ const buf = Buffer.alloc(1024);
2676
+ process.stdout.write(prompt);
2677
+ try {
2678
+ const n = readSync(0, buf, 0, buf.length, null);
2679
+ return n ? buf.toString("utf8", 0, n).trim() : null;
2680
+ } catch {
2681
+ return null;
2682
+ }
2683
+ }
2684
+
2685
+ /**
2686
+ * A yes/no question in the terminal: Enter — the default value (shown in upper case), an unclear answer — the
2687
+ * question again. Input closed or unreadable — the default value.
2688
+ */
2689
+ function ask(question: string, def: boolean) {
2690
+ for (;;) {
2691
+ const answer = readAnswer(`${question} [${def ? "Y/n" : "y/N"}] `)?.toLowerCase();
2692
+ if (!answer) return def;
2693
+ if (["y", "yes"].includes(answer)) return true;
2694
+ if (["n", "no"].includes(answer)) return false;
2695
+ }
2696
+ }
2697
+
2698
+ /**
2699
+ * A language question in the terminal: Enter — the default value, not a language code — the question again; input
2700
+ * closed — the default.
2701
+ */
2702
+ function askLanguage(def: string) {
2703
+ for (;;) {
2704
+ const answer = readAnswer(`${LANGUAGE_QUESTION} [${def}] `);
2705
+ if (!answer) return def;
2706
+ if (LANGUAGE_CODE.test(answer)) return answer;
2707
+ }
2708
+ }
2709
+
2710
+ /**
2711
+ * `--lang <code>` or `--lang=<code>` among the flags: the code and the other flags; a flag without a value — code "",
2712
+ * the code check will reject it.
2713
+ */
2714
+ function takeLang(flags: string[]): { lang: string | undefined; rest: string[] } {
2715
+ let lang: string | undefined;
2716
+ const rest: string[] = [];
2717
+ for (let i = 0; i < flags.length; i++) {
2718
+ const f = flags[i] ?? "";
2719
+ if (f === "--lang") lang = flags[++i] ?? "";
2720
+ else if (f.startsWith("--lang=")) lang = f.slice("--lang=".length);
2721
+ else rest.push(f);
2722
+ }
2723
+ return { lang, rest };
2724
+ }
2725
+
2726
+ /**
2727
+ * The `install` hint with auto. The Claude Code app rejects a merge without human review even in auto mode: the
2728
+ * machine's permission rule decides it, and that rule applies to all the machine's repositories — wider than the
2729
+ * project. So `install` names the rule instead of setting it: otherwise the owner learns about it from a refusal at
2730
+ * the end of the first task.
2731
+ */
2732
+ const MERGE_HINT = (language: string) =>
2733
+ [
2734
+ "Claude Code: for the session to merge its own PR, the machine needs a permission rule — the owner adds it, install doesn't change the settings:",
2735
+ ' ~/.claude/settings.json → permissions.allow: "Bash(gh pr merge *)"',
2736
+ ` without the rule the app rejects a merge without human review — the session ends with «${locale.names(language).lastLine.remaining} …», the merge is left to the owner`,
2737
+ ].join("\n");
2738
+
2739
+ /**
2740
+ * No permission to write — usually an agent's sandbox: Claude Code's refuses writes to `.claude/skills` of a project
2741
+ * and to `~/.agents`. Instead of a stack — what was refused and the same command for the owner's terminal: the install
2742
+ * order (installSkills) lets a repeat run finish it. Not a permission error — false, the caller handles it.
2743
+ * @returns the error was reported
2744
+ */
2745
+ function denied(e: unknown, argv: string[], global: boolean) {
2746
+ const err = e as { code?: unknown; path?: unknown; dest?: unknown };
2747
+ if (err?.code !== "EPERM" && err?.code !== "EACCES") return false;
2748
+ const file = String(err.dest ?? err.path ?? "");
2749
+ const home = os.homedir();
2750
+ const shown = global && file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : path.relative(projectRoot(), file);
2751
+ warn(`${argv[0]}: no permission to write ${shown} (${err.code}) — an agent's sandbox? Run the same command in your own terminal, a repeat run finishes it: ${RUN} ${argv.join(" ")}`);
2752
+ return true;
2753
+ }
2754
+
2755
+ const FLAGS: Record<string, string[]> = { install: ["-g", "--global", "--link", "--auto", "--no-auto"], check: ["-g", "--global", "--hook"], update: ["-g", "--global"], release: ["--dry-run"], pack: [] };
2756
+
2757
+ /** `pack <tag>`: the tag — the first argument that isn't a flag; the other arguments. */
2758
+ function takeTag(args: string[]): { tag: string | undefined; rest: string[] } {
2759
+ const i = args.findIndex((a) => !a.startsWith("-"));
2760
+ return i < 0 ? { tag: undefined, rest: args } : { tag: args[i], rest: args.filter((_, j) => j !== i) };
2761
+ }
2762
+
2763
+ async function main(argv: string[]): Promise<number> {
2764
+ const [cmd = "", ...args] = argv;
2765
+ // a skill's script — at this package's version, the project's: no launcher, the help is the script's
2766
+ if (Object.hasOwn(SCRIPTS, cmd)) return runScript(cmd, args);
2767
+ const help = (await helpOf(argv)) ?? (cmd === "help" ? await overview() : undefined);
2768
+ if (help) return note(help), 0;
2769
+ const global = args.includes("-g") || args.includes("--global");
2770
+ const hookMode = cmd === "check" && args.includes("--hook");
2771
+ // the launcher restarts before it checks the command and flags (`toRelease`): bunx keeps it as old as its first run,
2772
+ // and it mustn't reject what the release knows. "Not installed" needs no release: the hook in someone else's project
2773
+ // doesn't go to the network for nothing; release — only from a clone, pack — from a checkout
2774
+ if (cmd !== "release" && cmd !== "pack" && (cmd !== "check" || (hookMode ? [true, false].some((g) => target(g).installed) : target(global).installed))) {
2775
+ try {
2776
+ const code = toRelease(argv);
2777
+ if (code !== null) return code;
2778
+ } catch (e) {
2779
+ if (hookMode) return hook(e);
2780
+ if (cmd === "check") return process.stderr.write(unavailable(global, e).text + "\n"), 2;
2781
+ return warn(`${cmd}: ${reason(e)}`), 2;
2782
+ }
2783
+ }
2784
+ // --lang with a value — only for install, the tag — only for pack: for the other commands it's an unknown flag
2785
+ const { lang, rest } = cmd === "install" ? takeLang(args) : { lang: undefined, rest: args };
2786
+ const { tag, rest: flags } = cmd === "pack" ? takeTag(rest) : { tag: undefined, rest };
2787
+ const allowed = Object.hasOwn(HELP, cmd) ? FLAGS[cmd] : undefined;
2788
+ const unknown = allowed ? flags.filter((f) => !allowed.includes(f)) : [];
2789
+ if (!allowed || unknown.length) {
2790
+ process.stderr.write((!allowed && cmd ? `unknown command: ${cmd}\n\n` : unknown.length ? `unknown flag: ${unknown[0]}\n\n` : "") + (await overview()) + "\n");
2791
+ return 2;
2792
+ }
2793
+ if (lang !== undefined && (global || !LANGUAGE_CODE.test(lang))) {
2794
+ warn(`--lang <code> — the project language the agent works in: without -g, a BCP 47 code — ru, en, pt-BR${global ? "" : `; «${lang}» is not a language code`}`);
2795
+ return 2;
2796
+ }
2797
+ if (cmd === "release") return releaseCmd(flags.includes("--dry-run"));
2798
+ if (cmd === "pack") return pack(tag);
2799
+ if (cmd === "check") {
2800
+ if (hookMode) return hook();
2801
+ let r: Result;
2802
+ try {
2803
+ r = check(global);
2804
+ } catch (e) {
2805
+ r = unavailable(global, e);
2806
+ }
2807
+ r = withMachine(global, r);
2808
+ (r.code === 2 ? process.stderr : process.stdout).write(r.text + "\n");
2809
+ return r.code;
2810
+ }
2811
+ if (cmd === "update") {
2812
+ try {
2813
+ return update(global);
2814
+ } catch (e) {
2815
+ return denied(e, argv, global) || warn(`update: ${reason(e)}`), 2;
2816
+ }
2817
+ }
2818
+ const linkMode = flags.includes("--link");
2819
+ if (linkMode && (!global || !existsSync(path.join(SRC, ".git")))) {
2820
+ warn("--link — only with -g and only from an Elves clone: a project keeps the package version, not a link to a local clone");
2821
+ return 2;
2822
+ }
2823
+ const [on, off] = [flags.includes("--auto"), flags.includes("--no-auto")];
2824
+ if ((on || off) && (global || (on && off))) {
2825
+ warn("--auto and --no-auto — the project setting «a task entirely without asking»: without -g and only one of the two");
2826
+ return 2;
2827
+ }
2828
+ try {
2829
+ if (global) return installGlobal(SRC, linkMode), 0;
2830
+ const root = projectRoot();
2831
+ // questions — only here and only in a terminal: `update` and `check` (the hook at session start) go through
2832
+ // installProject without them
2833
+ const tty = isatty(0) && isatty(1);
2834
+ const language = lang ?? (tty ? askLanguage(languageOf(root)) : languageOf(root));
2835
+ const before = autoOf(root);
2836
+ const auto = on ? true : off ? false : tty ? ask(AUTO_QUESTION, before) : before;
2837
+ if (isElvesClone(root)) installClone(root, auto, language);
2838
+ else installProject(root, auto, language);
2839
+ if (auto) note(MERGE_HINT(language));
2840
+ return 0;
2841
+ } catch (e) {
2842
+ if (denied(e, argv, global)) return 2;
2843
+ throw e;
2844
+ }
2845
+ }
2846
+
2847
+ process.exitCode = await main(process.argv.slice(2));