@miroshnik/elves 2026.1019.2 → 2026.1020.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/elves.ts CHANGED
@@ -1686,7 +1686,7 @@ function rewriteCopyRefs(root: string) {
1686
1686
  * A mention of `.agents` that isn't the thin project's own: the manifest, the shim, `~/.agents` and `$HOME/.agents` of
1687
1687
  * the machine aren't one.
1688
1688
  */
1689
- const COPY_MENTION = /(?<![\w~}-]|~\/|HOME\/|HOME}\/)\.agents(?![\w-])(?!\/(?:elves\.json|slot\.sh)\b)/;
1689
+ const COPY_MENTION = /(?<![\w~}-]|~\/|HOME\/|HOME}\/)\.agents(?![\w-])(?!\\?\/(?:elves\\?\.json|slot\\?\.sh)\b)/;
1690
1690
 
1691
1691
  /**
1692
1692
  * What still names `.agents` once the copy is gone: exceptions `.agents/**` in linters, formatters and configs aren't
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miroshnik/elves",
3
- "version": "2026.1019.2",
3
+ "version": "2026.1020.0",
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": {
@@ -71,5 +71,5 @@
71
71
  "eslint-plugin-boundaries": "^7.2.0",
72
72
  "knip": "^6.41.0"
73
73
  },
74
- "gitHead": "006208c32b61d184886a5b77dbdffa39bba751f2"
74
+ "gitHead": "373c60f3e34f9333931d83450b27d1957a4278ba"
75
75
  }
@@ -16,7 +16,7 @@ no host port per app, no port search. The scheme and the reasons — epic
16
16
  "Environment" in Elves (`tests/capabilities/task-env/task-env.md`).
17
17
 
18
18
  ```bash
19
- bun --no-env-file <skill dir>/scripts/task-env.ts up [--profile <p>]... # the task's stack by the branch, its addresses
19
+ bun --no-env-file <skill dir>/scripts/task-env.ts up [<N>] [--profile <p>]... # the task's stack by the branch, its addresses
20
20
  bun --no-env-file <skill dir>/scripts/task-env.ts e2e [--profile <p>]... [-- <command>] # the e2e stack anew, the command with its addresses
21
21
  bun --no-env-file <skill dir>/scripts/task-env.ts down [<N>] # the task's stacks with their volumes
22
22
  bun --no-env-file <skill dir>/scripts/task-env.ts prune # stacks of the project's closed tasks
@@ -43,7 +43,7 @@ task's own Compose project, and nothing of it is shared with other tasks.
43
43
 
44
44
  | what | value |
45
45
  |---|---|
46
- | task | the number from the branch `[<area>/]<type>/<N>-<slug>`; no number (`main`, a worktree branch before the rename) — the main checkout's stack |
46
+ | task | the number from the branch `[<area>/]<type>/<N>-<slug>`; in the middle of a rebase — the branch being rebased (`rebase-merge/head-name`); no number — the main checkout's stack, but never from a linked worktree: there `up` and `down` without a number refuse (give `up <N>` or finish the rebase) |
47
47
  | `<project>` | the repository of `origin` (the last part of its URL without `.git`), no remote — the main checkout's directory; lowercase letters, digits and `-`. Not the directory Compose takes by default — a worktree's is random |
48
48
  | stack | Compose project `<project>-<N>`, e2e — `<project>-<N>-e2e`, without a task — `<project>`, `<project>-e2e` |
49
49
  | profiles | `--profile <name>` (repeated) goes to Compose: a heavy service in `profiles` runs only in a task that asks for it |
@@ -58,15 +58,17 @@ import { fileURLToPath } from "node:url";
58
58
  * name, #620).
59
59
  */
60
60
  export const HELP: Record<string, string> = {
61
- up: `task-env up [--profile <p>]...
61
+ up: `task-env up [<N>] [--profile <p>]...
62
62
 
63
63
  Brings the task's stack up — the Compose project <project>-<N> by the branch <type>/<N>-slug, <project> without a
64
- number — and prints its addresses: env.http services of .agents/elves.json through the proxy, published ports on
64
+ number (in the middle of a rebase — the branch being rebased; a linked worktree without a task branch is
65
+ refused: give N) — and prints its addresses: env.http services of .agents/elves.json through the proxy, published ports on
65
66
  127.0.0.1; the HTTP addresses go to the compose file's interpolation in TASK_ENV_<SERVICE>_URL; --profile — Compose
66
67
  profiles. Code 0 — up, 1 — a service failed (its state and log), 2 — not tried.
67
68
 
68
69
  Examples:
69
70
  task-env up
71
+ task-env up 42
70
72
  task-env up --profile worker`,
71
73
  e2e: `task-env e2e [--profile <p>]... [-- <command>...]
72
74
 
@@ -547,6 +549,35 @@ const git = (dir: string, ...args: string[]) => {
547
549
  return r.status === 0 ? r.stdout.trim() : "";
548
550
  };
549
551
 
552
+ /**
553
+ * The checkout's branch: `branch --show-current`, in the middle of a rebase (HEAD is detached, the branch is empty) — the
554
+ * branch being rebased from `rebase-merge/head-name` or `rebase-apply/head-name` of the worktree's git directory (#639).
555
+ */
556
+ export function currentBranch(root: string): string {
557
+ const now = git(root, "branch", "--show-current");
558
+ if (now) return now;
559
+ for (const dir of ["rebase-merge", "rebase-apply"]) {
560
+ const file = git(root, "rev-parse", "--path-format=absolute", "--git-path", `${dir}/head-name`);
561
+ if (!file || !existsSync(file)) continue;
562
+ const ref = readFileSync(file, "utf8").trim();
563
+ if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length);
564
+ }
565
+ return "";
566
+ }
567
+
568
+ /** A linked worktree, not the main checkout: its git directory differs from the repository's common one. */
569
+ export function isLinkedWorktree(root: string): boolean {
570
+ const dir = git(root, "rev-parse", "--path-format=absolute", "--git-dir");
571
+ const common = git(root, "rev-parse", "--path-format=absolute", "--git-common-dir");
572
+ return !!dir && !!common && dir !== common;
573
+ }
574
+
575
+ /** A linked worktree without a task branch: the main checkout's stack isn't its — the refusal, otherwise null. */
576
+ function noTaskRefusal(root: string, command: string): string | null {
577
+ if (!isLinkedWorktree(root) || taskBranch(currentBranch(root))) return null;
578
+ return `task-env ${command}: the worktree ${root} has no task branch (HEAD is detached or the branch isn't <type>/<N>-slug), and the main checkout's stack isn't taken from a worktree; finish the rebase or switch to the task branch, or give the number: task-env ${command} <N>`;
579
+ }
580
+
550
581
  /**
551
582
  * `<project>` of stacks and addresses: the repository of `origin` (the last part of its URL without `.git`), without a
552
583
  * remote — the main checkout's directory. Not the directory Compose takes by default: a worktree's is random
@@ -613,7 +644,7 @@ interface Project {
613
644
  http: Record<string, number>;
614
645
  }
615
646
 
616
- function projectOf(cwd: string): Project | string {
647
+ function projectOf(cwd: string, task?: number): Project | string {
617
648
  const root = git(cwd, "rev-parse", "--show-toplevel") || cwd;
618
649
  const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
619
650
  if (!file) return `no compose file in ${root} (${COMPOSE_FILES.join(", ")}) — the task's stack is the project's Compose`;
@@ -621,7 +652,7 @@ function projectOf(cwd: string): Project | string {
621
652
  const http = httpServices(root);
622
653
  if (typeof http === "string") return http;
623
654
  const files = [file, ...(extra ? [extra] : [])].flatMap((f) => ["-f", f]);
624
- return { root, files, name: projectName(root), task: taskBranch(git(root, "branch", "--show-current"))?.issue, http };
655
+ return { root, files, name: projectName(root), task: task ?? taskBranch(currentBranch(root))?.issue, http };
625
656
  }
626
657
 
627
658
  /** `docker compose` in cwd; vars — over the script's environment: Compose takes them into interpolation. */
@@ -952,11 +983,20 @@ function stackCommand(group: string, args: string[], env: NodeJS.ProcessEnv): nu
952
983
  // the stacks are found by the Compose project: the task's worktree may be gone already
953
984
  const root = git(process.cwd(), "rev-parse", "--show-toplevel") || process.cwd();
954
985
  if (group === "prune") return prune(root, env);
955
- return stackDown(root, args[0] ? Number(args[0]) : taskBranch(git(root, "branch", "--show-current"))?.issue, env);
986
+ if (!args[0]) {
987
+ const refusal = noTaskRefusal(root, "down");
988
+ if (refusal) return console.log(refusal), 2;
989
+ }
990
+ return stackDown(root, args[0] ? Number(args[0]) : taskBranch(currentBranch(root))?.issue, env);
956
991
  }
957
- const opts = profilesOf(args);
992
+ const number = group === "up" && /^\d+$/.test(args[0] ?? "") ? Number(args[0]) : undefined;
993
+ const opts = profilesOf(number === undefined ? args : args.slice(1));
958
994
  if (!opts || (group === "up" && opts.rest.length)) return undefined;
959
- const project = projectOf(process.cwd());
995
+ if (number === undefined) {
996
+ const refusal = noTaskRefusal(git(process.cwd(), "rev-parse", "--show-toplevel") || process.cwd(), group);
997
+ if (refusal) return console.log(refusal), 2;
998
+ }
999
+ const project = projectOf(process.cwd(), number);
960
1000
  if (typeof project === "string") return console.log(`task-env ${group}: ${project}`), 2;
961
1001
  return group === "up" ? stackUp(project, opts.profiles, env) : stackE2e(project, opts.profiles, opts.rest, env);
962
1002
  }