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