@miroshnik/elves 2026.1019.0 → 2026.1019.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -199,9 +199,9 @@ it.
199
199
  PR's CI — fast checks on the merge with it; red — I fix the root cause;
200
200
  `main` red by itself (exit code 3) — no merge and no fix in the PR: one bug
201
201
  for it, I wait for it to close.
202
- - A merge that deploys — I wait for the deploy on the merge SHA (`ci-wait`),
203
- the epic refresh in parallel; **then `github task close`**. Others'
204
- branches, stacks and unmerged work I don't touch.
202
+ - A merge that deploys — I wait for the deploy on the merge SHA (`ci-wait`)
203
+ **before `github task close`**. Others' branches, stacks and unmerged work
204
+ I don't touch.
205
205
  - Mechanics — the `github` skill, "Git, PRs and merging — mechanics";
206
206
  waiting for checks and deploys — `docs/pr-checks.md`.
207
207
 
@@ -222,8 +222,9 @@ folder's `exceptions/`, with an `#issue`, a reason and a ratchet. Test names
222
222
  are statements in the project language; a code task gets the scenarios
223
223
  section (future `it` names) before code. The PR body is `spec-diff` output;
224
224
  docs are generated (`spec-doc`) and published by CI to the `spec` branch. A
225
- recurring decision — a "Standard · …" issue and a rule in `tests/standards/`.
226
- The full canon and wiring — the `spec` skill (`canon.md`, `checks.md`).
225
+ recurring decision — a "Standard · …" issue and a rule in `tests/standards/`;
226
+ repeats in a PR's files — fixed in it. The full canon and wiring — the `spec`
227
+ skill (`canon.md`, `checks.md`).
227
228
 
228
229
  ## Tests and code
229
230
 
@@ -282,8 +283,8 @@ The full canon and wiring — the `spec` skill (`canon.md`, `checks.md`).
282
283
  skill) at `<service>.<N>.<project>.localhost`, not on a host port; others'
283
284
  stacks and busy ports I don't touch — `docs/parallel-checkouts.md`.
284
285
  - **A subagent gets a unit of ~50 turns** (one decision's folder, one file,
285
- one spec), not a 200-turn zone: it carries the same preamble (≈ 100k
286
- tokens), and its price is quadratic in turns. The assignment has the task
286
+ one spec), not a 200-turn zone: with the same ≈ 100k-token preamble its
287
+ price is quadratic in turns. The assignment has the task
287
288
  number after the word for "task" or at the start, the epic after the word
288
289
  for "epic" ("Language") — so `est` links its work. **Fewer turns:** a file
289
290
  in one full read, independent tool calls in one turn.
package/README.md CHANGED
@@ -155,7 +155,7 @@ No exaggeration: the spec format isn't the main token cost; what costs more is h
155
155
  You need `git` and [Bun](https://bun.sh) ≥ 1.2 — the installer (`bunx`) and the skill scripts run under it; a project's CI on Node adds the `oven-sh/setup-bun` step; the `github`, `est` and `dashboard` skills need [`gh`](https://cli.github.com) with the `project` scope (`gh auth refresh -s project`); `task-env` needs Docker with Compose (Docker Desktop on macOS).
156
156
 
157
157
  ```bash
158
- # onto the machine — the canon, skills and the hook for every agent it has; or a global package:
158
+ # onto the machine — the canon, skills and the hook for every agent it has, and the elves command on PATH; or a global package:
159
159
  # bun add -g @miroshnik/elves && elves install -g
160
160
  bunx @miroshnik/elves install -g
161
161
 
@@ -193,7 +193,7 @@ An agent's sandbox (Claude Code's Bash) doesn't let bunx write its cache `~/.bun
193
193
  - Updating — `update` bumps the package version: `package.json` and the lockfile, a few lines in the branch of the current task, committed with it. A project with a copy of the flow of an earlier release moves to a thin one in a task of its own, one per project — `check` prints its `task new`, the hook names the canon loaded twice until then: `update` removes the copy, its symlinks and `.gitignore` exceptions, leads imports and CI paths into the copy to the package and lists what else names `.agents`.
194
194
  - The manifest, the shim, `AGENTS.md` and `.claude/settings.json`, if the project's git ignores them (`.agents/` or `.claude/` in `.gitignore`), are un-ignored by `install` and `update` with exceptions — an Elves block at the end of `.gitignore`, committed with them; what an exception can't un-ignore (a directory excluded by a personal rule), and anything ignored until `update` runs — `❌` in the output of the install and of `check`.
195
195
 
196
- **Onto the machine** (`-g`) — the canon, the skills and the hook: a thin project keeps no copy of the canon, its agents get it from the machine. `~/.agents/elves/` — `AGENTS.md`, `claude/CLAUDE.md`, `docs/*.md`; Claude Code — symlinks `~/.claude/rules/elves.md` and `elves-claude.md` (loaded in every project without the external-imports dialog), `~/.claude/skills` and the `SessionStart` hook in `~/.claude/settings.json`; the other agents — their global rule file linking to the canon (`~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.copilot/copilot-instructions.md`, `~/.config/opencode/AGENTS.md`, `~/.config/amp/AGENTS.md`; a file of your own stays) and `~/.agents/skills`. A project that still has a copy loads the canon twice until it moves — the hook names it. A Claude Code cloud session gets the same from the project's `SessionStart` hook — `npm i -g bun@latest @miroshnik/elves && elves install -g` (the image's Bun lags behind), then `check --hook`; the machine's hook isn't there (the cloud doesn't read `~/.claude/settings.json`), an environment's setup script with the same command is an optional faster start. In an Elves clone with `--link` the machine's canon is the clone; its `.claude/CLAUDE.md` keeps the root `AGENTS.md` from loading a second time.
196
+ **Onto the machine** (`-g`) — the canon, the skills and the hook: a thin project keeps no copy of the canon, its agents get it from the machine. `~/.agents/elves/` — `AGENTS.md`, `claude/CLAUDE.md`, `docs/*.md`; Claude Code — symlinks `~/.claude/rules/elves.md` and `elves-claude.md` (loaded in every project without the external-imports dialog), `~/.claude/skills` and the `SessionStart` hook in `~/.claude/settings.json`; the other agents — their global rule file linking to the canon (`~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`, `~/.copilot/copilot-instructions.md`, `~/.config/opencode/AGENTS.md`, `~/.config/amp/AGENTS.md`; a file of your own stays) and `~/.agents/skills`. The `elves` command in the terminal — in `~/.local/bin`, or the first directory of `PATH` inside the home directory you can write to: with `--link` a symlink to the clone's `bin/elves.ts`, otherwise a shim to the machine's release (`bunx @miroshnik/elves@<version>`) that `update -g` rewrites; an `elves` of your own (a script, a global package) stays, and `check -g` names it, a command missing or of another release, an `elves` earlier on `PATH`, and — when `PATH` has no such directory — the command that adds `~/.local/bin`. A project that still has a copy loads the canon twice until it moves — the hook names it. A Claude Code cloud session gets the same from the project's `SessionStart` hook — `npm i -g bun@latest @miroshnik/elves && elves install -g` (the image's Bun lags behind), then `check --hook`; the machine's hook isn't there (the cloud doesn't read `~/.claude/settings.json`), an environment's setup script with the same command is an optional faster start. In an Elves clone with `--link` the machine's canon is the clone; its `.claude/CLAUDE.md` keeps the root `AGENTS.md` from loading a second time.
197
197
 
198
198
  **Personal configuration** — everything that depends on specific repositories (the registry for `est`, your own model prices, notes) — lives outside the flow, in `~/.config/elves`; another directory — the `ELVES_CONFIG_DIR` variable, read by all skill scripts. `ELVES_PRIVATE=<path>` with `install -g` makes `~/.config/elves` a symlink to a private checkout; with `ELVES_CONFIG_DIR` the symlink isn't needed — the variable points at the checkout itself.
199
199
 
package/bin/elves.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * bunx @miroshnik/elves install — into the project: the git root (or the current directory)
8
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)
9
+ * elves install -g — the same from the npm package (npm i -g @miroshnik/elves);
10
+ * install -g puts the elves command on PATH (`installCommand`)
10
11
  * bun <Elves clone>/bin/elves.ts install -g --link — from a clone: symlinks to the clone, edits show at once
11
12
  * bunx @miroshnik/elves check [-g] — is the install behind: 0 — no, 1 — yes, 2 — can't tell
12
13
  * bunx @miroshnik/elves update [-g] — bring the install up to date
@@ -76,7 +77,7 @@
76
77
  * proxy name) instead of holding copies. No dependencies, only node: APIs.
77
78
  */
78
79
  import { execFileSync, spawnSync } from "node:child_process";
79
- import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
80
+ import { accessSync, constants as fsConstants, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
80
81
  import os from "node:os";
81
82
  import path from "node:path";
82
83
  import { isatty } from "node:tty";
@@ -223,7 +224,10 @@ terminal — the previous one. In an Elves clone writes only auto and language,
223
224
  from the clone itself.
224
225
  -g — onto the machine: the canon in ~/.agents/elves with Claude Code's rules ~/.claude/rules/elves*.md and the global
225
226
  rule files of Codex, Gemini CLI, Copilot, OpenCode and Amp linking to it (someone else's file stays), the skills
226
- (~/.agents/skills, ~/.claude/skills) and the SessionStart hook; without a terminal too — a cloud session's project hook
227
+ (~/.agents/skills, ~/.claude/skills), the SessionStart hook and the elves command in the terminal — in ~/.local/bin or
228
+ the first directory of PATH inside the home directory that can be written: with --link a symlink to the clone's
229
+ bin/elves.ts, otherwise a shim to the machine's release (bunx ${PACKAGE}@<version>); someone else's elves there stays
230
+ (!), no such directory — no command, check -g names how to add one; without a terminal too — a cloud session's project hook
227
231
  or setup script (CLAUDE_CODE_REMOTE=true) gets the canon and skills without the hook: the cloud doesn't read
228
232
  ~/.claude/settings.json. From the npm package (npm i -g / bun add -g ${PACKAGE}) — the release of its version, without
229
233
  the network. --link — from an Elves clone: symlinks to the clone instead of copies, edits show up immediately.
@@ -242,7 +246,9 @@ mode (auto) and the language, holds the compose file to the task isolation rules
242
246
  docker compose; the task-env skill), names a test that runs standards and architecture again next to test:spec (○, the
243
247
  ready test without them) and warns (!) if a script in slot calls turbo and turbo.json doesn't pass the queue variables
244
248
  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
249
+ text (the exit code stays the same); for the machine the elves command missing or of another release is falling
250
+ behind, someone else's elves in its place or one earlier on PATH — !, no directory for it — ! with the command that
251
+ adds ~/.local/bin to PATH; a thin project behind — update in this session's task (the package version in
246
252
  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
247
253
  task new command of the github skill, one task per project. For the machine names the task-env proxy of *.localhost
248
254
  addresses — running, not running or running
@@ -260,7 +266,7 @@ Examples:
260
266
  update: `elves update [-g]
261
267
 
262
268
  Brings it up to date: a --link clone — git pull --ff-only, the machine's copy — install of the latest release: the
263
- canon, the skills and the hook; a project — install of the latest release: ${PACKAGE} in package.json and the
269
+ canon, the skills, the hook and the elves shim of that release; a project — install of the latest release: ${PACKAGE} in package.json and the
264
270
  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
265
271
  --help), by hand after it — a check of the project that knows the queue wrapper by the copy's path (. .agents/slot.sh
266
272
  now), AI_DEV_SLOT* in the project's code → ELVES_SLOT*, formatting the shortened imports, git add -A before the first
@@ -1794,6 +1800,150 @@ function agentRules(home: string, has: (dir: string, cmd?: string) => boolean) {
1794
1800
  }
1795
1801
  }
1796
1802
 
1803
+ /**
1804
+ * The `elves` command in the terminal (#632): `bunx @miroshnik/elves …` and `bun <clone>/bin/elves.ts …` aren't a
1805
+ * command, and the global package's bin directory (`~/.bun/bin`) may not be on PATH at all — Bun from Homebrew.
1806
+ * Where — `commandDir`; what — with `--link` a symlink to the clone's `bin/elves.ts` (its `sh` shebang works through a
1807
+ * symlink), otherwise a shim to the machine's release through bunx (`commandShim`): the bunx directory is temporary,
1808
+ * and the command matches the canon on the machine — `update -g` rewrites it with the release. Ours (`ownCommand`) is
1809
+ * replaced; someone else's `elves` in that directory — a script of the user's, a global package — stays with a `!`
1810
+ * line, and so does an `elves` earlier on PATH that the shell runs instead. No directory for it — no command: the `!`
1811
+ * line of `check -g` has the command that adds `~/.local/bin` to PATH. Not in a cloud session: there the flow comes
1812
+ * by `npm i -g` (`CLOUD_FLOW`), and its `elves` is the command.
1813
+ */
1814
+ const COMMAND = "elves";
1815
+ /** The shim's line by which the install knows its own file. */
1816
+ const COMMAND_MARK = "# Elves: the machine's elves command";
1817
+ /** What a dry run names next to the command's path. */
1818
+ const COMMAND_NOTE = "the elves command in the terminal";
1819
+
1820
+ /** PATH directories: absolute, in order, without repeats. */
1821
+ const pathDirs = () => [...new Set((process.env.PATH ?? "").split(path.delimiter).filter((d) => path.isAbsolute(d)).map((d) => path.resolve(d)))];
1822
+
1823
+ /** A directory the install can write into. */
1824
+ function writableDir(dir: string) {
1825
+ try {
1826
+ accessSync(dir, fsConstants.W_OK);
1827
+ return statSync(dir).isDirectory();
1828
+ } catch {
1829
+ return false;
1830
+ }
1831
+ }
1832
+
1833
+ /** A file the shell runs: a regular file (through symlinks) with the execute bit. */
1834
+ function runnable(file: string) {
1835
+ try {
1836
+ accessSync(file, fsConstants.X_OK);
1837
+ return statSync(file).isFile();
1838
+ } catch {
1839
+ return false;
1840
+ }
1841
+ }
1842
+
1843
+ /** Under dir, dir itself aside — by the path as given or by the real one (macOS /var → /private/var). */
1844
+ function inside(dir: string, p: string) {
1845
+ const real = (x: string) => {
1846
+ try {
1847
+ return realpathSync(x);
1848
+ } catch {
1849
+ return x;
1850
+ }
1851
+ };
1852
+ return [
1853
+ [dir, p],
1854
+ [real(dir), real(p)],
1855
+ ].some(([d, x]) => {
1856
+ const rel = path.relative(d!, x!);
1857
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
1858
+ });
1859
+ }
1860
+
1861
+ /**
1862
+ * The install's own command: a symlink to a clone's `bin/elves.ts` — not into node_modules, that is a global package;
1863
+ * a clone gone or moved too — or a file with COMMAND_MARK.
1864
+ */
1865
+ function ownCommand(file: string) {
1866
+ const st = lstat(file);
1867
+ if (st?.isSymbolicLink()) {
1868
+ const to = readlinkSync(file);
1869
+ return /(?:^|\/)bin\/elves\.ts$/.test(to) && !to.split("/").includes("node_modules");
1870
+ }
1871
+ if (!st?.isFile() || st.size > 4096) return false;
1872
+ try {
1873
+ return readFileSync(file, "utf8").includes(COMMAND_MARK);
1874
+ } catch {
1875
+ return false;
1876
+ }
1877
+ }
1878
+
1879
+ /**
1880
+ * The command's directory: where PATH already has ours; otherwise `~/.local/bin` — if PATH has it (missing — the
1881
+ * install creates it); otherwise the first directory of PATH inside the home directory that can be written — not
1882
+ * outside it: system directories need root, and the install doesn't ask for it. null — no such directory on PATH.
1883
+ */
1884
+ function commandDir(home: string): string | null {
1885
+ const dirs = pathDirs();
1886
+ const own = dirs.find((d) => ownCommand(path.join(d, COMMAND)));
1887
+ if (own) return own;
1888
+ const mine = dirs.filter((d) => inside(home, d));
1889
+ const local = path.join(home, ".local/bin");
1890
+ if (mine.includes(local) && (!lstat(local) || writableDir(local))) return local;
1891
+ return mine.find(writableDir) ?? null;
1892
+ }
1893
+
1894
+ /**
1895
+ * The shim of a machine with a copy: the release it was installed from by its npm package — the bunx package of the
1896
+ * same version as the canon; a release unknown — by the SHA (a clone without `--link`, a package pinned by SHA),
1897
+ * without it — the latest release.
1898
+ */
1899
+ function commandShim(src: string) {
1900
+ const version = release ? npmVersion(release) : null;
1901
+ const sha = version ? null : sourceSha(src);
1902
+ const spec = version ? `${PACKAGE}@${version}` : sha ? `github:${SOURCE}#${sha}` : PACKAGE;
1903
+ const what = version ? `release ${release}` : sha ? `Elves ${short(sha)}` : "the latest release";
1904
+ return ["#!/bin/sh", `${COMMAND_MARK} — ${what}; set by \`${RUN} install -g\`, update -g rewrites it`, `exec bunx --silent ${spec} "$@"`, ""].join("\n");
1905
+ }
1906
+
1907
+ /**
1908
+ * No directory for the command: what the owner runs — `~/.local/bin` into PATH in the shell's profile and the same
1909
+ * install with it on PATH.
1910
+ */
1911
+ function pathCommand(src: string, linkMode: boolean) {
1912
+ const shell = path.basename(process.env.SHELL ?? "");
1913
+ const profile = shell === "zsh" ? "~/.zshrc" : shell === "bash" ? "~/.bashrc" : "~/.profile";
1914
+ const again = linkMode ? `bun --no-env-file ${shWord(path.join(src, "bin/elves.ts"))} install -g --link` : `${RUN} update -g`;
1915
+ return `mkdir -p ~/.local/bin && echo 'export PATH="$HOME/.local/bin:$PATH"' >> ${profile} && PATH="$HOME/.local/bin:$PATH" ${again}`;
1916
+ }
1917
+
1918
+ /** The `elves` command (see COMMAND): a dry run names what it would change and the `!` lines. */
1919
+ function installCommand(src: string, home: string, linkMode: boolean) {
1920
+ if (remote()) return;
1921
+ const shown = (p: string) => (inside(home, p) ? `~/${path.relative(home, p)}` : p);
1922
+ const say = (file: string, text: string) => (dry ? void change("!", file, { note: text }) : warn(`${shown(file)} — ${text}`));
1923
+ const dir = commandDir(home);
1924
+ if (!dir) {
1925
+ const text = `${COMMAND_NOTE} isn't installed: PATH has no directory inside the home directory that can be written — add ~/.local/bin: ${pathCommand(src, linkMode)}`;
1926
+ return dry ? void change("!", path.join(home, ".local/bin", COMMAND), { note: text }) : void note(text);
1927
+ }
1928
+ const dst = path.join(dir, COMMAND);
1929
+ const st = lstat(dst);
1930
+ if (st && !ownCommand(dst)) return say(dst, `someone else's elves (a script of the user's, a global package) — left as is; the machine's command goes here once it's removed: ${RUN} update -g`);
1931
+ const target = path.join(src, "bin/elves.ts");
1932
+ const text = linkMode ? linkText(target, dst, home) : commandShim(src);
1933
+ const same = linkMode ? Boolean(st?.isSymbolicLink()) && readlinkSync(dst) === text : Boolean(st?.isFile()) && runnable(dst) && readFileSync(dst, "utf8") === text;
1934
+ if (!same && dry) change(st ? "~" : "+", dst, { note: COMMAND_NOTE });
1935
+ else if (!same) {
1936
+ if (st) rmSync(dst);
1937
+ mkdirSync(dir, { recursive: true });
1938
+ if (linkMode) symlinkSync(text, dst);
1939
+ else writeFileSync(dst, text, { mode: 0o755 });
1940
+ note(`${shown(dst)} — ${COMMAND_NOTE}: ${linkMode ? `→ ${target}` : (/^exec (.*) "\$@"$/m.exec(text)?.[1] ?? "")}`);
1941
+ }
1942
+ const dirs = pathDirs();
1943
+ const first = dirs.slice(0, dirs.indexOf(dir)).map((d) => path.join(d, COMMAND)).find(runnable);
1944
+ if (first) say(first, `comes first on PATH: elves runs it, not ${shown(dst)} — remove it or put ${shown(dir)} before ${shown(path.dirname(first))} in PATH`);
1945
+ }
1946
+
1797
1947
  /**
1798
1948
  * The URL of a GitHub origin on the repository's name before the rename (LEGACY_SOURCE, the name is case-insensitive) —
1799
1949
  * the same URL on SOURCE: the form, the credentials, the port and `.git` stay; another repository or host — null.
@@ -1835,8 +1985,8 @@ const remote = () => process.env.CLAUDE_CODE_REMOTE === "true";
1835
1985
  /**
1836
1986
  * The machine (#557): the canon `~/.agents/elves/` — a copy or, with `--link`, a symlink to the clone — with Claude
1837
1987
  * Code's rules `~/.claude/rules/elves*.md` (the same `installRules` and `claudeRules` as in a project: what a previous
1838
- * install left under the flow's former name moves), the other agents' rule files (`agentRules`), skills and the hook.
1839
- * Claude Code is on the machine — `~/.claude`, `claude` on PATH or a cloud session: its project hook (`cloudHook`) or
1988
+ * install left under the flow's former name moves), the other agents' rule files (`agentRules`), skills, the `elves`
1989
+ * command in the terminal (`installCommand`) and the hook. Claude Code is on the machine — `~/.claude`, `claude` on PATH or a cloud session: its project hook (`cloudHook`) or
1840
1990
  * its environment's setup script runs in a fresh home. The cloud doesn't read `~/.claude/settings.json` — no hook there.
1841
1991
  */
1842
1992
  function installGlobal(src: string, linkMode: boolean) {
@@ -1879,6 +2029,8 @@ function installGlobal(src: string, linkMode: boolean) {
1879
2029
  note(`${CONFIG} → ${priv}`);
1880
2030
  }
1881
2031
  }
2032
+ // the last step: an agent's sandbox may refuse a directory of PATH — the canon, the skills and the hook are in place
2033
+ installCommand(src, home, linkMode);
1882
2034
  }
1883
2035
 
1884
2036
  /**
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  locale_default
4
- } from "../../spec/scripts/harness-wqrt5gpa.js";
4
+ } from "../../spec/scripts/harness-tdg4dnq3.js";
5
5
 
6
6
  // skills/github/scripts/github.ts
7
7
  import { spawnSync } from "node:child_process";
@@ -11,12 +11,14 @@ import path from "node:path";
11
11
  import { parseArgs } from "node:util";
12
12
  var N = locale_default.names(locale_default.projectLanguage());
13
13
  var canonViews = () => [
14
- { name: N.views.board, layout: "BOARD_LAYOUT" },
15
- { name: N.views.table, layout: "TABLE_LAYOUT" },
14
+ { name: N.views.board, layout: "BOARD_LAYOUT", columns: ["TITLE", STATUS, "LABELS", "SUB_ISSUES_PROGRESS"] },
15
+ { name: N.views.table, layout: "TABLE_LAYOUT", columns: ["TITLE", STATUS, "LABELS", "ASSIGNEES", "SUB_ISSUES_PROGRESS", N.fields.estimate], groupBy: STATUS },
16
16
  { name: N.views.roadmap, layout: "ROADMAP_LAYOUT" }
17
17
  ];
18
+ var BUILTIN_COLUMNS = { TITLE: "Title", ASSIGNEES: "Assignees", LABELS: "Labels", SUB_ISSUES_PROGRESS: "Sub-issues progress" };
18
19
  var STATUS = "Status";
19
20
  var statusOptions = () => [N.status.backlog, N.status.inProgress, N.status.done];
21
+ var STATUS_COLORS = ["GREEN", "YELLOW", "PURPLE"];
20
22
  var numberFields = () => [N.fields.estimate, N.fields.actual, N.fields.tokens, N.fields.cost];
21
23
  var issueTypes = () => [N.issueTypes.task, N.issueTypes.bug, N.issueTypes.epic];
22
24
  var PRIORITY = "Priority";
@@ -36,6 +38,12 @@ var ISSUE_LABEL_LIMIT = 100;
36
38
  var WORKFLOW_ADDED = "Item added to project";
37
39
  var WORKFLOW_CLOSED = "Item closed";
38
40
  var WORKFLOW_AUTO_ADD = "Auto-add to project";
41
+ var WORKFLOW_MERGED = "Pull request merged";
42
+ var WORKFLOW_AUTO_CLOSE = "Auto-close issue";
43
+ var WORKFLOW_SUB_ISSUES = "Auto-add sub-issues to project";
44
+ var WORKFLOW_ARCHIVE = "Auto-archive items";
45
+ var WORKFLOWS = [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_MERGED, WORKFLOW_AUTO_CLOSE, WORKFLOW_AUTO_ADD, WORKFLOW_SUB_ISSUES, WORKFLOW_ARCHIVE];
46
+ var ARCHIVE_FILTER = "is:issue is:closed updated:<@today-2w";
39
47
  var DEFAULT_TEMPLATE = "miroshnik/6";
40
48
  var RULESET = "elves";
41
49
  var FORMER_RULESET = "ai-dev";
@@ -122,6 +130,8 @@ var Q = {
122
130
  id number name layout filter
123
131
  sortByFields(first: 5) { nodes { direction field { ... on ProjectV2FieldCommon { name } } } }
124
132
  verticalGroupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
133
+ groupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
134
+ fields(first: 50) { nodes { ... on ProjectV2FieldCommon { id name dataType } } }
125
135
  }
126
136
  }
127
137
  fields(first: 100) {
@@ -650,7 +660,9 @@ function loadProject(io, id, withItems = true) {
650
660
  layout: v.layout,
651
661
  filter: v.filter ?? null,
652
662
  sortBy: (v.sortByFields?.nodes ?? []).map((s) => s.field?.name).filter(Boolean),
653
- columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f) => f?.name).filter(Boolean)
663
+ columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f) => f?.name).filter(Boolean),
664
+ groupBy: (v.groupByFields?.nodes ?? []).map((f) => f?.name).filter(Boolean),
665
+ columns: (v.fields?.nodes ?? []).filter((f) => f?.id).map((f) => ({ id: f.id, name: f.name, dataType: f.dataType }))
654
666
  })),
655
667
  fields: p.fields.nodes.filter((f) => f && f.id).map((f) => ({ id: f.id, name: f.name, dataType: f.dataType, isIssueField: !!f.isIssueField, options: f.options ?? null })),
656
668
  workflows: p.workflows.nodes.filter(Boolean),
@@ -692,7 +704,12 @@ function analyze(s) {
692
704
  for (const other of s.repo.linked.filter((x) => x.id !== p.id)) {
693
705
  link(`another project is linked: ${q(other.title)} (${other.url})`, { kind: "confirm", text: `unlink project ${q(other.title)} from the repository`, mutations: [{ op: "UnlinkProject", input: { projectId: other.id, repositoryId: s.repo.id } }] });
694
706
  }
695
- const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, no filter, no others`);
707
+ const fieldAlias = fieldAliases();
708
+ const columnKey = (f) => BUILTIN_FIELDS.has(f.dataType) ? f.dataType : fieldAlias[f.name] ?? f.name;
709
+ const columnName = (key) => p.fields.find((f) => columnKey(f) === key)?.name ?? BUILTIN_COLUMNS[key] ?? key;
710
+ const columnFields = (keys) => keys.map((k) => p.fields.find((f) => columnKey(f) === k)).filter((f) => !!f);
711
+ const configuration = (keys) => ({ visibleFieldIds: columnFields(keys).map((f) => f.id) });
712
+ const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, columns and grouping, no filter, no others`);
696
713
  const canonNames = new Set(canonViews().map((v) => v.name));
697
714
  const spare = p.views.filter((v) => !canonNames.has(v.name));
698
715
  for (const c of canonViews()) {
@@ -703,7 +720,8 @@ function analyze(s) {
703
720
  spare.splice(spare.indexOf(cand), 1);
704
721
  views(`missing ${q(c.name)}`, guarded(`rename view ${q(cand.name)} → ${q(c.name)}`, { op: "UpdateView", input: { viewId: cand.id, name: c.name } }));
705
722
  } else {
706
- views(`missing ${q(c.name)}`, api(`create view ${q(c.name)} (${c.layout})`, { op: "CreateView", input: { projectId: p.id, name: c.name, layout: c.layout } }));
723
+ const input = { projectId: p.id, name: c.name, layout: c.layout, ...c.columns ? { configuration: configuration(c.columns) } : {} };
724
+ views(`missing ${q(c.name)}`, api(`create view ${q(c.name)} (${c.layout})`, { op: "CreateView", input }));
707
725
  }
708
726
  continue;
709
727
  }
@@ -714,13 +732,26 @@ function analyze(s) {
714
732
  if (c.layout === "BOARD_LAYOUT" && v.layout === c.layout && v.columnsBy[0] !== STATUS) {
715
733
  views(`${q(c.name)} columns not by Status`, ui(`${q(c.name)}: View options → Column by → Status → Save`, `${p.url}/views/${v.number}`));
716
734
  }
735
+ if (c.columns) {
736
+ const have = v.columns.map(columnKey);
737
+ const missing = c.columns.filter((k) => !have.includes(k)).map((k) => q(columnName(k)));
738
+ const extra = v.columns.filter((f) => !c.columns.includes(columnKey(f))).map((f) => q(f.name));
739
+ if (missing.length || extra.length) {
740
+ const why = [missing.length ? `missing ${missing.join(", ")}` : "", extra.length ? `extra ${extra.join(", ")}` : ""].filter(Boolean).join("; ");
741
+ const set = columnFields(c.columns).map((f) => f.name).join(", ");
742
+ views(`${q(c.name)} columns: ${why}`, api(`${q(c.name)}: columns ${set}`, { op: "UpdateView", input: { viewId: v.id, configuration: configuration(c.columns) } }));
743
+ }
744
+ }
745
+ if (c.groupBy && v.layout === c.layout && v.groupBy[0] !== c.groupBy) {
746
+ views(`${q(c.name)} not grouped by ${c.groupBy}`, ui(`${q(c.name)}: View options → Group by → ${c.groupBy} → Save`, `${p.url}/views/${v.number}`));
747
+ }
717
748
  }
718
749
  for (const v of spare)
719
750
  views(`extra ${q(v.name)}`, guarded(`delete view ${q(v.name)}`, { op: "DeleteView", input: { viewId: v.id } }));
720
- const status = add("status", `${STATUS}: ${statusOptions().join(" → ")}`);
751
+ const status = add("status", `${STATUS}: ${statusOptions().join(" → ")} (${STATUS_COLORS.join(", ")})`);
721
752
  const sf = p.fields.find((f) => f.name === STATUS && f.options);
722
753
  if (!sf) {
723
- status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name) => ({ name, color: "GRAY", description: "" })) } }));
754
+ status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name, i) => ({ name, color: STATUS_COLORS[i], description: "" })) } }));
724
755
  } else if (sf.options.map((o) => o.name).join(`
725
756
  `) !== statusOptions().join(`
726
757
  `)) {
@@ -730,15 +761,17 @@ function analyze(s) {
730
761
  return i < 0 ? null : left.splice(i, 1)[0];
731
762
  };
732
763
  const aliases = statusAliases();
733
- const next = statusOptions().map((name) => {
764
+ const next = statusOptions().map((name, i) => {
734
765
  const o = take((x) => x.name === name) ?? take((x) => aliases[x.name.toLowerCase()] === name);
735
- return o ? { id: o.id, name, color: o.color, description: o.description ?? "" } : { name, color: "GRAY", description: "" };
766
+ return o ? { id: o.id, name, color: STATUS_COLORS[i], description: o.description ?? "" } : { name, color: STATUS_COLORS[i], description: "" };
736
767
  });
737
768
  const drop = left.length ? `; delete ${left.map((o) => q(o.name)).join(", ")}` : "";
738
769
  status(`options: ${sf.options.map((o) => o.name).join(", ") || "none"}`, guarded(`${STATUS}: ${statusOptions().join(", ")} (rename the existing ones keeping the id${drop})`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
770
+ } else if (sf.options.some((o, i) => o.color !== STATUS_COLORS[i])) {
771
+ const next = sf.options.map((o, i) => ({ id: o.id, name: o.name, color: STATUS_COLORS[i], description: o.description ?? "" }));
772
+ status(`colors: ${sf.options.map((o) => `${o.name} ${o.color}`).join(", ")}`, api(`${STATUS}: colors ${STATUS_COLORS.join(", ")} (keeping the id)`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
739
773
  }
740
774
  const fields = add("fields", `Fields ${numberFields().map(q).join(", ")} (Number), no other custom ones`);
741
- const fieldAlias = fieldAliases();
742
775
  const renamed = new Set;
743
776
  for (const name of numberFields()) {
744
777
  const f = p.fields.find((x) => x.name === name);
@@ -804,13 +837,17 @@ function analyze(s) {
804
837
  types(`extra ${q(x.name)}`, { kind: "confirm", text: `disable type ${q(x.name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: x.id, isEnabled: false } }] });
805
838
  }
806
839
  }
807
- const wf = add("workflows", `Workflows ${[WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD].map(q).join(", ")} are on`);
840
+ const wf = add("workflows", `Workflows ${WORKFLOWS.map(q).join(", ")} are on`);
808
841
  const how = {
809
842
  [WORKFLOW_ADDED]: `Edit → Set value → ${STATUS}: ${N.status.backlog}`,
810
843
  [WORKFLOW_CLOSED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
811
- [WORKFLOW_AUTO_ADD]: `Edit → repository ${s.repo.name}, filter is:issue is:open (Enter)`
844
+ [WORKFLOW_MERGED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
845
+ [WORKFLOW_AUTO_CLOSE]: `Edit → ${STATUS}: ${N.status.done}`,
846
+ [WORKFLOW_AUTO_ADD]: `Edit → repository ${s.repo.name}, filter is:issue is:open (Enter)`,
847
+ [WORKFLOW_SUB_ISSUES]: "Edit",
848
+ [WORKFLOW_ARCHIVE]: `Edit → filter ${ARCHIVE_FILTER} (Enter)`
812
849
  };
813
- for (const name of [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD]) {
850
+ for (const name of WORKFLOWS) {
814
851
  const w = p.workflows.find((x) => x.name === name);
815
852
  if (w?.enabled)
816
853
  continue;
@@ -1750,11 +1787,16 @@ function cloudBrief(io, slug, issue) {
1750
1787
  state: N.comments.state,
1751
1788
  questions: N.sections.questions
1752
1789
  };
1753
- for (const line of [N.queue.cloud, N.queue.cloudDoc, N.queue.cloudClose, N.queue.cloudDispatcher, N.queue.cloudQuestion])
1790
+ for (const line of [N.queue.cloud, N.queue.cloudDoc, N.queue.repeats, N.queue.cloudClose, N.queue.cloudDispatcher, N.queue.cloudQuestion])
1754
1791
  io.out(locale_default.fill(line, vars));
1755
1792
  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`);
1756
1793
  }
1757
1794
  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";
1795
+ function openPrOf(io, slug) {
1796
+ const [owner, name] = slug.split("/");
1797
+ const recent = (graphql(io, Q.RecentPrs, { owner, name })?.repository?.pullRequests?.nodes ?? []).filter((p) => p.state === "OPEN");
1798
+ return (i) => i.prs.find((p) => p.state === "OPEN")?.number ?? recent.find((p) => isTaskBranch(p.headRefName ?? "", i.number) || closingInBody(p.body ?? "").includes(i.number))?.number;
1799
+ }
1758
1800
  function cmdTaskQueue(io, slug, o) {
1759
1801
  const want = o.type === undefined ? undefined : typeAliases()[o.type.toLowerCase()];
1760
1802
  if (o.type !== undefined && (!want || want === N.issueTypes.epic))
@@ -1780,9 +1822,7 @@ function cmdTaskQueue(io, slug, o) {
1780
1822
  io.out("○ the search gave 100 open tasks — the queue sees only them: narrow it with --epic");
1781
1823
  numbers = found.filter((x) => x?.state === "OPEN").map((x) => x.number);
1782
1824
  }
1783
- const [owner, name] = slug.split("/");
1784
- const recent = (graphql(io, Q.RecentPrs, { owner, name })?.repository?.pullRequests?.nodes ?? []).filter((p) => p.state === "OPEN");
1785
- const openPr = (i) => i.prs.find((p) => p.state === "OPEN")?.number ?? recent.find((p) => isTaskBranch(p.headRefName ?? "", i.number) || closingInBody(p.body ?? "").includes(i.number))?.number;
1825
+ const openPr = openPrOf(io, slug);
1786
1826
  const handed = handedOut(io, slug);
1787
1827
  const states = new Map;
1788
1828
  const isOpen = (n) => {
@@ -2123,7 +2163,7 @@ function cmdTaskAssignment(io, slug, number, o = {}) {
2123
2163
  remaining: N.lastLine.remaining
2124
2164
  };
2125
2165
  const resume = restart.length ? [N.queue.resume] : [];
2126
- const lines = o.session ? [N.queue.sessionLead, ...resume, N.queue.sessionIs, N.queue.sessionName, N.queue.sessionWait, N.queue.sessionFound, N.queue.sessionQuestion, N.queue.sessionEnd] : [N.queue.lead, ...resume, N.queue.subagent, N.queue.session, N.queue.wait, N.queue.git, N.queue.subagents, N.queue.live, N.queue.refresh, N.queue.found, N.queue.question, N.queue.answer];
2166
+ const lines = o.session ? [N.queue.sessionLead, ...resume, N.queue.sessionIs, N.queue.sessionName, N.queue.sessionWait, N.queue.repeats, N.queue.sessionFound, N.queue.sessionQuestion, N.queue.sessionEnd] : [N.queue.lead, ...resume, N.queue.subagent, N.queue.session, N.queue.wait, N.queue.git, N.queue.subagents, N.queue.live, N.queue.refresh, N.queue.repeats, N.queue.found, N.queue.question, N.queue.answer];
2127
2167
  for (const line of lines)
2128
2168
  io.out(locale_default.fill(line, vars));
2129
2169
  if (o.session) {
@@ -2270,6 +2310,7 @@ function cmdTaskActualize(io, slug, number, o = { assignment: false }) {
2270
2310
  refreshMechanics(io, slug, { number, merged: prs.length > 0, epic, open });
2271
2311
  if (!open.length)
2272
2312
  return io.out(`○ refresh not needed: epic #${epic.number} has no open tasks besides #${number}. ${later}`), 0;
2313
+ const ctx = o.assignment ? taskContext(io, slug) : null;
2273
2314
  const vars = { task: N.subagentPrompt.task, epicWord: N.subagentPrompt.epic, number, epic: epic.number, repo: slug };
2274
2315
  const lead = locale_default.fill(N.actualize.lead, vars);
2275
2316
  if (!o.assignment) {
@@ -2287,8 +2328,18 @@ function cmdTaskActualize(io, slug, number, o = { assignment: false }) {
2287
2328
  lines.push(locale_default.fill(N.actualize.files, { pr: p.number }), ...files.map(fileLine));
2288
2329
  }
2289
2330
  lines.push("", locale_default.fill(N.actualize.epic, { ...vars, title: epic.title }), textBlock(epic.body), "", N.actualize.openTasks);
2331
+ const openPr = openPrOf(io, slug);
2332
+ const target = (n) => {
2333
+ const i = loadIssue(io, slug, n);
2334
+ const status = itemIn(ctx, i)?.status ?? null;
2335
+ const pr = openPr(i);
2336
+ const work = [status === N.status.inProgress ? locale_default.fill(N.actualize.taskStatus, { status }) : "", pr === undefined ? "" : locale_default.fill(N.actualize.taskPr, { pr })].filter(Boolean);
2337
+ if (work.length)
2338
+ return [locale_default.fill(N.actualize.inWork, { work: work.join(", ") })];
2339
+ return status === N.status.backlog ? [locale_default.fill(N.actualize.backlog, { status: locale_default.fill(N.actualize.taskStatus, { status }) })] : [];
2340
+ };
2290
2341
  for (const t of open)
2291
- lines.push("", locale_default.fill(N.actualize.openTask, { number: t.number, title: t.title }), textBlock(t.body));
2342
+ lines.push("", locale_default.fill(N.actualize.openTask, { number: t.number, title: t.title }), ...target(t.number), textBlock(t.body));
2292
2343
  const copies = linkCopies(epic, open);
2293
2344
  if (copies.length)
2294
2345
  lines.push("", N.actualize.copies, ...copies.map((c) => locale_default.fill(N.actualize.copy, c)));
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  locale_default
3
- } from "./harness-wqrt5gpa.js";
3
+ } from "./harness-tdg4dnq3.js";
4
4
  import {
5
5
  invariant2,
6
6
  codeOnly2,
7
7
  envNamesInCode2
8
- } from "./harness-ay62ws50.js";
8
+ } from "./harness-bpyj4s91.js";
9
9
 
10
10
  // skills/spec/scripts/architecture.ts
11
11
  import { readdirSync, readFileSync } from "node:fs";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  locale_default
3
- } from "./harness-wqrt5gpa.js";
3
+ } from "./harness-tdg4dnq3.js";
4
4
  var __create = Object.create;
5
5
  var __getProtoOf = Object.getPrototypeOf;
6
6
  var __defProp = Object.defineProperty;
@@ -64,6 +64,10 @@ var en_default = {
64
64
  epic: "## Epic #{epic} «{title}»",
65
65
  openTasks: "## The epic's open tasks — its sub-issues, not a list in the epic's description",
66
66
  openTask: "### #{number} «{title}»",
67
+ backlog: "{status} — a repeat finding of the same kind (the same helper, module or standard) goes here as an item in «What to do» (and a scenario, if needed), not into a new task.",
68
+ inWork: "{work} — the task is already going: a repeat finding of the same kind isn't appended here but filed as a new task.",
69
+ taskStatus: "Status «{status}»",
70
+ taskPr: "PR #{pr} is open",
67
71
  noBody: "(no description)",
68
72
  copies: "## Copies of links in text — remove them: GitHub keeps the links (sub-issue, blocked by)",
69
73
  copy: "- #{number}: «{line}»",
@@ -71,8 +75,8 @@ var en_default = {
71
75
  commentAnswer: "- #{number}, question {k}: «{text}» — {url}",
72
76
  checks: "## Checks by meaning — yours (canon — reference.md of the github skill, «Epic refresh on task close»); the mechanics — blocked by on done tasks, the questions label without open questions, milestones without open tasks — the script did",
73
77
  scope: "You change only GitHub tasks — description, «{questions}», links, status; the github skill script — `{script}`: not done — `task drop <N>`, a new one — `task new … --branch-type <type> --epic {epic}` (it sets the estimate itself); an answer at a question — `task answer <N> <k> \"<answer>\"`. Don't touch code, branches or git; don't close #{number}, epic #{epic} or their milestones — task close does that after you.",
74
- comment: "The result — always, as the last step: a comment on epic #{epic} (`gh issue comment {epic} --repo {repo}`), one line «{report}» — after the colon what you fixed, nothing to fix — «the block is current». By it `task close {number}` sees the refresh is over: without it the close refuses.",
75
- answer: "Answer — fixes one per line with task numbers and questions to the owner, if any; otherwise «The block is current».",
78
+ comment: "The result — always, as the last step: a comment on epic #{epic} (`gh issue comment {epic} --repo {repo}`), one line «{report}» — after the colon what you fixed, a repeat finding — with where it went: «appended to #N» or «filed #M»; nothing to fix — «the block is current». By it `task close {number}` sees the refresh is over: without it the close refuses.",
79
+ answer: "Answer — fixes one per line with task numbers (a repeat finding — «appended to #N» or «filed #M») and questions to the owner, if any; otherwise «The block is current».",
76
80
  linkWords: "blocked by|blocks|after|depends on|waits for",
77
81
  report: "Refresh after #{number}, result: {text}",
78
82
  mechanics: "Refresh after #{number}, script: {fixes}.",
@@ -92,6 +96,7 @@ var en_default = {
92
96
  subagents: "- writing subagents — launch as needed, no limit: each gets its own worktree and «{task} #{number}» at the start of its prompt (`est` links its work by it); bring their commits into your branch, remove their worktrees and branches yourself after the merge — what didn't go, name in the answer;",
93
97
  live: "- while any of your subagents works, don't answer: wait for their answers — a background subagent wakes you with its answer; an answer with live subagents looks like a failure, and the dispatcher would give the task to a second executor next to you. Can't wait — answer `waits for subagents: <which and what they do>`: it isn't `failed`, there will be no second executor;",
94
98
  refresh: '- the epic refresh — the subagent `task actualize` prints, without a worktree of its own (in yours) and not in the background (`run_in_background: false`): its answer — in the same turn, then `{script} task close {number}`, and only then the answer to the dispatcher: once you answer, a worktree without changes is removed, and a background subagent is left without a directory; a task without a PR (a data edit, a reconciliation, research) — first close the issue as completed with a result comment: `gh issue close {number} --repo {repo} --reason completed --comment "<result>"`;',
99
+ repeats: "- before the PR — repeats in the files the task changes anyway: fix them in the same PR, don't file a task for them; a repeat outside the task's files is the epic refresh's;",
95
100
  found: "- work found outside the task — `{script} task new …` (work of the task's epic — with `--epic`: the dispatcher picks it up) — name it in the answer: whether to queue it is the owner's call;",
96
101
  question: "- a question without a clear recommendation — into «{questions}» of the task; don't start the code that depends on it and answer the dispatcher: the owner's answer comes back here.",
97
102
  answer: "The answer to the dispatcher — one line: `merged #<PR>` · `question: <the question, options, recommendation>` · `waits for subagents: <which and what they do>` · `failed: <what stopped and where>`.",
@@ -522,6 +527,10 @@ var ru_default = {
522
527
  epic: "## Эпик #{epic} «{title}»",
523
528
  openTasks: "## Открытые задачи эпика — его подзадачи (sub-issues), а не список в описании эпика",
524
529
  openTask: "### #{number} «{title}»",
530
+ backlog: "{status} — находку-повтор того же рода (тот же помощник, модуль или стандарт) дописать сюда пунктом в «Что сделать» (и сценарий, если нужен), новую задачу не заводить.",
531
+ inWork: "{work} — задача уже идёт: находку-повтор того же рода сюда не дописывать, а завести новой задачей.",
532
+ taskStatus: "Статус «{status}»",
533
+ taskPr: "PR #{pr} открыт",
525
534
  noBody: "(описания нет)",
526
535
  copies: "## Копии связей в тексте — убрать: связи держит GitHub (sub-issue, blocked by)",
527
536
  copy: "- #{number}: «{line}»",
@@ -529,8 +538,8 @@ var ru_default = {
529
538
  commentAnswer: "- #{number}, вопрос {k}: «{text}» — {url}",
530
539
  checks: "## Проверки по смыслу — твои (канон — reference.md скилла github, «Epic refresh on task close»); механику — blocked by на выполненных задачах, метку вопросов без открытых вопросов, milestone без открытых задач — сделал скрипт",
531
540
  scope: 'Меняешь только задачи GitHub — описание, «{questions}», связи, статус; скрипт скилла github — `{script}`: без выполнения — `task drop <N>`, новая — `task new … --branch-type <тип> --epic {epic}` (оценку ставит сама); ответ у вопроса — `task answer <N> <k> "<ответ>"`. Код, ветки и git не трогать; #{number}, эпик #{epic} и их milestone не закрывать — это сделает task close после тебя.',
532
- comment: "Итог — всегда, последним шагом: комментарий к эпику #{epic} (`gh issue comment {epic} --repo {repo}`) одной строкой «{report}» — после двоеточия что поправил, а править нечего — «блок актуален». По нему `task close {number}` видит, что актуализация закончена: без него закрытие откажет.",
533
- answer: "Ответ — правки по строке с номерами задач и вопросы владельцу, если есть; иначе «Блок актуален».",
541
+ comment: "Итог — всегда, последним шагом: комментарий к эпику #{epic} (`gh issue comment {epic} --repo {repo}`) одной строкой «{report}» — после двоеточия что поправил, находку-повтор — с тем, куда она ушла: «дописано в #N» или «заведена #M»; править нечего — «блок актуален». По нему `task close {number}` видит, что актуализация закончена: без него закрытие откажет.",
542
+ answer: "Ответ — правки по строке с номерами задач (находка-повтор — «дописано в #N» или «заведена #M») и вопросы владельцу, если есть; иначе «Блок актуален».",
534
543
  linkWords: "после|зависит от|заблокирована|заблокирован|заблокировано|блокирует|ждёт|ждет",
535
544
  report: "Актуализация после #{number}, итог: {text}",
536
545
  mechanics: "Актуализация после #{number}, скрипт: {fixes}.",
@@ -550,6 +559,7 @@ var ru_default = {
550
559
  subagents: "- пишущих субагентов запускай по необходимости, без лимита: каждому — свой worktree и «{task} #{number}» в начале промпта (по нему `est` привяжет его работу); их коммиты перенеси в свою ветку, их worktree и ветки после мержа убери сам — что не убралось, назови в ответе;",
551
560
  live: "- пока работает хоть один твой субагент, ответ не отдавай: жди их ответов — фоновый субагент будит тебя своим ответом; ответ при живых субагентах выглядит провалом, и диспетчер отдал бы задачу второму исполнителю рядом с тобой. Ждать нельзя — ответь `ждёт субагентов: <какие и что делают>`: это не «сломалось», второго исполнителя не будет;",
552
561
  refresh: '- актуализация эпика — субагент, которого печатает `task actualize`, без своего worktree (в твоём) и не в фоне (`run_in_background: false`): его ответ — в том же ходе, затем `{script} task close {number}`, и только потом ответ диспетчеру: стоит тебе ответить, worktree без изменений убирают, и фоновый субагент остаётся без каталога; у задачи без PR (правка данных, сверка, исследование) — сначала закрой issue как выполненную с итоговым комментарием: `gh issue close {number} --repo {repo} --reason completed --comment "<итог>"`;',
562
+ repeats: "- перед PR — повторы в файлах, которые задача и так правит: исправь в том же PR, новую задачу на них не заводи; повтор вне файлов задачи — дело актуализации эпика;",
553
563
  found: "- найденная работа вне задачи — `{script} task new …` (работа эпика задачи — с `--epic`: диспетчер её подхватит) — назови её в ответе: ставить ли её в очередь, решает владелец;",
554
564
  question: "- вопрос без ясной рекомендации — в «{questions}» задачи; код, который от него зависит, не начинай и ответь диспетчеру: ответ владельца придёт сюда же.",
555
565
  answer: "Ответ диспетчеру — одной строкой: `влито #<PR>` · `вопрос: <вопрос, варианты, рекомендация>` · `ждёт субагентов: <какие и что делают>` · `сломалось: <что и где остановилось>`.",
@@ -23,8 +23,8 @@ import {
23
23
  envNamesInCode2,
24
24
  dotenvNames2,
25
25
  envVars2
26
- } from "./harness-ay62ws50.js";
27
- import"./harness-wqrt5gpa.js";
26
+ } from "./harness-bpyj4s91.js";
27
+ import"./harness-tdg4dnq3.js";
28
28
  export {
29
29
  ENV_SERVICE2 as ENV_SERVICE,
30
30
  codeFiles2 as codeFiles,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miroshnik/elves",
3
- "version": "2026.1019.0",
3
+ "version": "2026.1019.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": {
@@ -71,5 +71,5 @@
71
71
  "eslint-plugin-boundaries": "^7.2.0",
72
72
  "knip": "^6.41.0"
73
73
  },
74
- "gitHead": "42576112e74dfdc383f64b0edb5e08a24ff4afa2"
74
+ "gitHead": "006208c32b61d184886a5b77dbdffa39bba751f2"
75
75
  }
@@ -60,6 +60,10 @@
60
60
  "epic": "## Epic #{epic} «{title}»",
61
61
  "openTasks": "## The epic's open tasks — its sub-issues, not a list in the epic's description",
62
62
  "openTask": "### #{number} «{title}»",
63
+ "backlog": "{status} — a repeat finding of the same kind (the same helper, module or standard) goes here as an item in «What to do» (and a scenario, if needed), not into a new task.",
64
+ "inWork": "{work} — the task is already going: a repeat finding of the same kind isn't appended here but filed as a new task.",
65
+ "taskStatus": "Status «{status}»",
66
+ "taskPr": "PR #{pr} is open",
63
67
  "noBody": "(no description)",
64
68
  "copies": "## Copies of links in text — remove them: GitHub keeps the links (sub-issue, blocked by)",
65
69
  "copy": "- #{number}: «{line}»",
@@ -67,8 +71,8 @@
67
71
  "commentAnswer": "- #{number}, question {k}: «{text}» — {url}",
68
72
  "checks": "## Checks by meaning — yours (canon — reference.md of the github skill, «Epic refresh on task close»); the mechanics — blocked by on done tasks, the questions label without open questions, milestones without open tasks — the script did",
69
73
  "scope": "You change only GitHub tasks — description, «{questions}», links, status; the github skill script — `{script}`: not done — `task drop <N>`, a new one — `task new … --branch-type <type> --epic {epic}` (it sets the estimate itself); an answer at a question — `task answer <N> <k> \"<answer>\"`. Don't touch code, branches or git; don't close #{number}, epic #{epic} or their milestones — task close does that after you.",
70
- "comment": "The result — always, as the last step: a comment on epic #{epic} (`gh issue comment {epic} --repo {repo}`), one line «{report}» — after the colon what you fixed, nothing to fix — «the block is current». By it `task close {number}` sees the refresh is over: without it the close refuses.",
71
- "answer": "Answer — fixes one per line with task numbers and questions to the owner, if any; otherwise «The block is current».",
74
+ "comment": "The result — always, as the last step: a comment on epic #{epic} (`gh issue comment {epic} --repo {repo}`), one line «{report}» — after the colon what you fixed, a repeat finding — with where it went: «appended to #N» or «filed #M»; nothing to fix — «the block is current». By it `task close {number}` sees the refresh is over: without it the close refuses.",
75
+ "answer": "Answer — fixes one per line with task numbers (a repeat finding — «appended to #N» or «filed #M») and questions to the owner, if any; otherwise «The block is current».",
72
76
  "linkWords": "blocked by|blocks|after|depends on|waits for",
73
77
  "report": "Refresh after #{number}, result: {text}",
74
78
  "mechanics": "Refresh after #{number}, script: {fixes}.",
@@ -88,6 +92,7 @@
88
92
  "subagents": "- writing subagents — launch as needed, no limit: each gets its own worktree and «{task} #{number}» at the start of its prompt (`est` links its work by it); bring their commits into your branch, remove their worktrees and branches yourself after the merge — what didn't go, name in the answer;",
89
93
  "live": "- while any of your subagents works, don't answer: wait for their answers — a background subagent wakes you with its answer; an answer with live subagents looks like a failure, and the dispatcher would give the task to a second executor next to you. Can't wait — answer `waits for subagents: <which and what they do>`: it isn't `failed`, there will be no second executor;",
90
94
  "refresh": "- the epic refresh — the subagent `task actualize` prints, without a worktree of its own (in yours) and not in the background (`run_in_background: false`): its answer — in the same turn, then `{script} task close {number}`, and only then the answer to the dispatcher: once you answer, a worktree without changes is removed, and a background subagent is left without a directory; a task without a PR (a data edit, a reconciliation, research) — first close the issue as completed with a result comment: `gh issue close {number} --repo {repo} --reason completed --comment \"<result>\"`;",
95
+ "repeats": "- before the PR — repeats in the files the task changes anyway: fix them in the same PR, don't file a task for them; a repeat outside the task's files is the epic refresh's;",
91
96
  "found": "- work found outside the task — `{script} task new …` (work of the task's epic — with `--epic`: the dispatcher picks it up) — name it in the answer: whether to queue it is the owner's call;",
92
97
  "question": "- a question without a clear recommendation — into «{questions}» of the task; don't start the code that depends on it and answer the dispatcher: the owner's answer comes back here.",
93
98
  "answer": "The answer to the dispatcher — one line: `merged #<PR>` · `question: <the question, options, recommendation>` · `waits for subagents: <which and what they do>` · `failed: <what stopped and where>`.",
@@ -60,6 +60,10 @@
60
60
  "epic": "## Эпик #{epic} «{title}»",
61
61
  "openTasks": "## Открытые задачи эпика — его подзадачи (sub-issues), а не список в описании эпика",
62
62
  "openTask": "### #{number} «{title}»",
63
+ "backlog": "{status} — находку-повтор того же рода (тот же помощник, модуль или стандарт) дописать сюда пунктом в «Что сделать» (и сценарий, если нужен), новую задачу не заводить.",
64
+ "inWork": "{work} — задача уже идёт: находку-повтор того же рода сюда не дописывать, а завести новой задачей.",
65
+ "taskStatus": "Статус «{status}»",
66
+ "taskPr": "PR #{pr} открыт",
63
67
  "noBody": "(описания нет)",
64
68
  "copies": "## Копии связей в тексте — убрать: связи держит GitHub (sub-issue, blocked by)",
65
69
  "copy": "- #{number}: «{line}»",
@@ -67,8 +71,8 @@
67
71
  "commentAnswer": "- #{number}, вопрос {k}: «{text}» — {url}",
68
72
  "checks": "## Проверки по смыслу — твои (канон — reference.md скилла github, «Epic refresh on task close»); механику — blocked by на выполненных задачах, метку вопросов без открытых вопросов, milestone без открытых задач — сделал скрипт",
69
73
  "scope": "Меняешь только задачи GitHub — описание, «{questions}», связи, статус; скрипт скилла github — `{script}`: без выполнения — `task drop <N>`, новая — `task new … --branch-type <тип> --epic {epic}` (оценку ставит сама); ответ у вопроса — `task answer <N> <k> \"<ответ>\"`. Код, ветки и git не трогать; #{number}, эпик #{epic} и их milestone не закрывать — это сделает task close после тебя.",
70
- "comment": "Итог — всегда, последним шагом: комментарий к эпику #{epic} (`gh issue comment {epic} --repo {repo}`) одной строкой «{report}» — после двоеточия что поправил, а править нечего — «блок актуален». По нему `task close {number}` видит, что актуализация закончена: без него закрытие откажет.",
71
- "answer": "Ответ — правки по строке с номерами задач и вопросы владельцу, если есть; иначе «Блок актуален».",
74
+ "comment": "Итог — всегда, последним шагом: комментарий к эпику #{epic} (`gh issue comment {epic} --repo {repo}`) одной строкой «{report}» — после двоеточия что поправил, находку-повтор — с тем, куда она ушла: «дописано в #N» или «заведена #M»; править нечего — «блок актуален». По нему `task close {number}` видит, что актуализация закончена: без него закрытие откажет.",
75
+ "answer": "Ответ — правки по строке с номерами задач (находка-повтор — «дописано в #N» или «заведена #M») и вопросы владельцу, если есть; иначе «Блок актуален».",
72
76
  "linkWords": "после|зависит от|заблокирована|заблокирован|заблокировано|блокирует|ждёт|ждет",
73
77
  "report": "Актуализация после #{number}, итог: {text}",
74
78
  "mechanics": "Актуализация после #{number}, скрипт: {fixes}.",
@@ -88,6 +92,7 @@
88
92
  "subagents": "- пишущих субагентов запускай по необходимости, без лимита: каждому — свой worktree и «{task} #{number}» в начале промпта (по нему `est` привяжет его работу); их коммиты перенеси в свою ветку, их worktree и ветки после мержа убери сам — что не убралось, назови в ответе;",
89
93
  "live": "- пока работает хоть один твой субагент, ответ не отдавай: жди их ответов — фоновый субагент будит тебя своим ответом; ответ при живых субагентах выглядит провалом, и диспетчер отдал бы задачу второму исполнителю рядом с тобой. Ждать нельзя — ответь `ждёт субагентов: <какие и что делают>`: это не «сломалось», второго исполнителя не будет;",
90
94
  "refresh": "- актуализация эпика — субагент, которого печатает `task actualize`, без своего worktree (в твоём) и не в фоне (`run_in_background: false`): его ответ — в том же ходе, затем `{script} task close {number}`, и только потом ответ диспетчеру: стоит тебе ответить, worktree без изменений убирают, и фоновый субагент остаётся без каталога; у задачи без PR (правка данных, сверка, исследование) — сначала закрой issue как выполненную с итоговым комментарием: `gh issue close {number} --repo {repo} --reason completed --comment \"<итог>\"`;",
95
+ "repeats": "- перед PR — повторы в файлах, которые задача и так правит: исправь в том же PR, новую задачу на них не заводи; повтор вне файлов задачи — дело актуализации эпика;",
91
96
  "found": "- найденная работа вне задачи — `{script} task new …` (работа эпика задачи — с `--epic`: диспетчер её подхватит) — назови её в ответе: ставить ли её в очередь, решает владелец;",
92
97
  "question": "- вопрос без ясной рекомендации — в «{questions}» задачи; код, который от него зависит, не начинай и ответь диспетчеру: ответ владельца придёт сюда же.",
93
98
  "answer": "Ответ диспетчеру — одной строкой: `влито #<PR>` · `вопрос: <вопрос, варианты, рекомендация>` · `ждёт субагентов: <какие и что делают>` · `сломалось: <что и где остановилось>`.",
@@ -97,9 +97,15 @@ Every repository has **exactly one** GitHub Project linked to the repo
97
97
  per epic/area (an area = an epic inside the shared project). All tasks are
98
98
  added to the project right on creation (`github task new`). Required:
99
99
 
100
- - **Three views**, with exactly these names: `Board` (BOARD_LAYOUT, columns
101
- by `Status`), `Table` (TABLE_LAYOUT), `Roadmap` (ROADMAP_LAYOUT).
102
- - **The `Status` field** with options `Backlog` → `In progress` → `Done`.
100
+ - **Three views**, with exactly these names, as in the template:
101
+ - `Board` (BOARD_LAYOUT, columns by `Status`) — cards show Title, Status,
102
+ Labels, Sub-issues progress;
103
+ - `Table` (TABLE_LAYOUT, grouped by `Status`) — columns Title, Status,
104
+ Labels, Assignees, Sub-issues progress, «Estimate, h»;
105
+ - `Roadmap` (ROADMAP_LAYOUT).
106
+ - **The `Status` field** with options `Backlog` → `In progress` → `Done`,
107
+ colored `GREEN`, `YELLOW`, `PURPLE` (a new GitHub project's colors);
108
+ option descriptions are the project's own.
103
109
  I change the status **myself, immediately**, without waiting to be asked
104
110
  (`github task status <N> <status>`):
105
111
  - a new task — I set Backlog on creation;
@@ -118,12 +124,15 @@ added to the project right on creation (`github task new`). Required:
118
124
  (those closed as not planned don't count — they are no longer in the
119
125
  project).
120
126
  Safety net — the project's built-in workflows "Item added to project" →
121
- Backlog, "Item closed" → Done and "Auto-add to project" (new open issues of
122
- the repository get into the project by themselves): the status gets set
123
- even if there is no one to set it (a cloud session, another agent, closing
124
- from the GitHub UI). The agent sets the status anyway; a task closed as not
125
- planned it removes from the project — otherwise the workflow puts it into
126
- Done.
127
+ Backlog, "Item closed" and "Pull request merged" → Done, "Auto-close issue"
128
+ (Done → the issue is closed), "Auto-add to project" (new open issues of
129
+ the repository get into the project by themselves) and "Auto-add sub-issues
130
+ to project": the status gets set even if there is no one to set it (a cloud
131
+ session, another agent, closing from the GitHub UI). The agent sets the
132
+ status anyway; a task closed as not planned it removes from the project —
133
+ otherwise the workflow puts it into Done. "Auto-archive items" (`is:issue
134
+ is:closed updated:<@today-2w`) takes a task closed and untouched for two
135
+ weeks off the board and the table.
127
136
  - **The `Estimate, h` field** (Number) — the agent's active hours
128
137
  (`AGENTS.md`, section "Estimate and actual — the `est` skill"). Set **only
129
138
  by `github task new`** (the `est` script inside) — from analogs with
@@ -149,8 +158,11 @@ added to the project right on creation (`github task new`). Required:
149
158
  unblocked. A personal account has no issue fields — no priority is kept.
150
159
  `github task new` sets it (`--priority`, `Medium` by default).
151
160
 
152
- Nothing extra: there are no other fields (Iteration, Readiness…) or views
153
- until they are added here. Readiness — the status and *blocked by*; stages —
161
+ Nothing extra: there are no other fields (Iteration, Readiness…), views or
162
+ columns until they are added here. What the public API doesn't read — the
163
+ slice by a field, the sub-issue hierarchy, column widths, the project's
164
+ description and README — isn't in the canon: a copy of the template carries
165
+ it, nothing checks it. Readiness — the status and *blocked by*; stages —
154
166
  milestones (the `Milestone` field).
155
167
 
156
168
  The project — the `github` skill: `project check` once per session before
@@ -234,12 +246,20 @@ line `○ refresh not needed`, no subagent. Five checks:
234
246
  of subtasks in the epic, an order, "⛔" or "after #N" marks — is removed.
235
247
  5. **Repetition is a standard.** A decision that, in what shipped, repeats an
236
248
  existing one in two or more places — a `Standard · …` issue
237
- (a rule in `tests/standards/` and a refactoring). First a search among the
238
- open `Standard · …`: one exists — don't file another; an extension of a
239
- neighboring one — a comment in it. A standard's priority — by the cost of
240
- the mistake it prevents; standards of one area — as an epic: otherwise
241
- they are born faster than they are worked off. Something deferred as "not
242
- in this PR" — an issue or dropped explicitly, not forgotten.
249
+ (a rule in `tests/standards/` and a refactoring); a repeat in the files a
250
+ task changes anyway is fixed in its PR by its executor, without a task.
251
+ First — an open task of the epic of the same kind (the same helper, module
252
+ or standard) in Backlog: the finding is an item in its «What to do» (and a
253
+ scenario, if needed), not a new task; one In progress or with an open PR is
254
+ already going — a new task (the assignment names each one's status). Then
255
+ a search among the open `Standard · …`: one exists — don't file another; an
256
+ extension of a neighboring one — a comment in it. The result names where
257
+ the finding went: «appended to #N» or «filed #M». A standard's priority —
258
+ by the cost of the mistake it prevents; standards of one area — as an epic:
259
+ otherwise they are born faster than they are worked off (#636: seven tasks
260
+ in a day on one helper's copies, each refresh finding the next). Something
261
+ deferred as "not in this PR" — an issue or dropped explicitly, not
262
+ forgotten.
243
263
 
244
264
  What was fixed — one comment on the epic, the history of decisions in one
245
265
  place; it is the subagent's last step, always: nothing to fix — «the block is
@@ -313,16 +333,23 @@ description is rewritten.
313
333
 
314
334
  1. The project is linked to the repository, it is the only one, its title =
315
335
  the repository name.
316
- 2. Views `Board` (BOARD, columns by `Status`), `Table` (TABLE), `Roadmap`
317
- (ROADMAP), filters empty, no others.
318
- 3. `Status`: `Backlog` → `In progress` → `Done`.
336
+ 2. Views `Board` (BOARD, columns by `Status`), `Table` (TABLE, grouped by
337
+ `Status`), `Roadmap` (ROADMAP), filters empty, no others; the columns of
338
+ `Board` and `Table` — the canon's set (a built-in field by its type, the
339
+ rest by name), `fix` sets them in the template's order. The roadmap's
340
+ columns aren't compared: the API gives it the default ones.
341
+ 3. `Status`: `Backlog` → `In progress` → `Done`, colors `GREEN`, `YELLOW`,
342
+ `PURPLE`; descriptions aren't compared.
319
343
  4. Fields «Estimate, h», «Actual, h», «Tokens, M», «Cost, $» (Number), no
320
344
  custom fields beyond the canon.
321
345
  5. Organization only: `Priority` — the organization's issue field, connected,
322
346
  `Table` and `Board` are sorted by it; issue types — exactly «Task»,
323
347
  «Bug», «Epic». A personal account has none — a `➖` line.
324
- 6. Workflows "Item added to project" (→ Backlog), "Item closed" (→ Done),
325
- "Auto-add to project" (`is:issue is:open`) are on.
348
+ 6. The template's workflows are on: "Item added to project" (→ Backlog),
349
+ "Item closed" and "Pull request merged" (→ Done), "Auto-close issue",
350
+ "Auto-add to project" (`is:issue is:open`), "Auto-add sub-issues to
351
+ project", "Auto-archive items" (`is:issue is:closed updated:<@today-2w`).
352
+ Others aren't checked.
326
353
  7. Closed tasks — in Done; those closed without being done (not planned,
327
354
  duplicate) — outside the project.
328
355
  8. The repository's open tasks — in the project, each has a `Status`.
@@ -379,7 +406,8 @@ description is rewritten.
379
406
  ### fix: who does what
380
407
 
381
408
  - **API — the script itself:** linking, a template copy or a new project,
382
- missing views and fields, view layout, removing a filter, `Status` options,
409
+ missing views and fields, view layout and columns, removing a filter,
410
+ `Status` options and colors,
383
411
  the `Priority` issue field, task statuses, adding open tasks, removing tasks
384
412
  closed without being done from the project, decision labels (creation,
385
413
  color), the default-branch ruleset (creation, editing its own), merge
@@ -409,14 +437,19 @@ description is rewritten.
409
437
  deleted (options were replaced without ids): pick it again.
410
438
  - "Item added to project": trigger `issue, pull request` (default), Set
411
439
  value → `Status: Backlog`.
412
- - "Item closed": Set value → `Status: Done`.
440
+ - "Item closed", "Pull request merged": Set value → `Status: Done`.
441
+ - "Auto-close issue": `Status: Done` → close the issue.
413
442
  - "Auto-add to project": the repository — this project's, filter
414
443
  `is:issue is:open`, Enter — the "See N existing items" counter should
415
444
  update. Without `is:open` an updated closed task (a not planned one too)
416
445
  comes back into the project. Auto-add doesn't add already existing tasks —
417
446
  `fix` adds them.
418
- - **Sorting by Priority, board columns:** the view → the `View` button on the
419
- right → `Sort by: Priority` / `Column by: Status` → `Save view` → the dialog
447
+ - "Auto-add sub-issues to project": nothing to set — Save and turn on.
448
+ - "Auto-archive items": filter `is:issue is:closed updated:<@today-2w`,
449
+ Enter.
450
+ - **Sorting by Priority, board columns, table grouping:** the view → the
451
+ `View` button on the right → `Sort by: Priority` / `Column by: Status` /
452
+ `Group by: Status` → `Save view` → the dialog
420
453
  "Save display options for …?" → `Save`. Escape closes the dialog without
421
454
  saving: the sorting stays only in the URL (`?sortedBy…`), and the tab's
422
455
  unsaved marker lies — `check` verifies the result.
@@ -435,8 +468,9 @@ description is rewritten.
435
468
  Done and leaves a step to check the target in the UI.
436
469
  - **`Status` options are renamed keeping the same id**
437
470
  (`updateProjectV2Field`, `singleSelectOptions[].id`): task values and the
438
- "Item closed" target are kept. Replacing options without ids gives new ids —
439
- task statuses vanish, "Item closed" sets the deleted `Done`.
471
+ "Item closed" target are kept, recoloring too. Replacing options without
472
+ ids gives new ids — task statuses vanish, "Item closed" sets the deleted
473
+ `Done`.
440
474
  - **The template** — the elves project (`miroshnik/6`): everything the API
441
475
  lacks is configured there once. A copy (`copyProjectV2`) carries over views
442
476
  with their settings, fields and configured workflows, except auto-add; it
@@ -444,12 +478,16 @@ description is rewritten.
444
478
  is unavailable (someone else's account) or `--template none` — the project is
445
479
  created from scratch (`createProjectV2`) and brought up via the API:
446
480
  `Todo / In Progress / Done` → the canon with the same ids, `View 1` →
447
- `Table`, the rest is created.
481
+ `Table`, the rest is created; the table's grouping is a UI step.
448
482
  - **The view filter is empty:** `iteration:@current` without an Iteration field
449
483
  silently hides tasks from the board.
450
484
  - **`Priority` is the organization's issue field** (`isIssueField`), not a
451
485
  project field of the same name. The API reads the sorting (`sortByFields`)
452
- but doesn't set it; the board grouping too (`verticalGroupByFields`).
486
+ but doesn't set it; the board grouping and the table grouping too
487
+ (`verticalGroupByFields`, `groupByFields`).
488
+ - **A view's columns are set** (`updateProjectV2View`,
489
+ `configuration.visibleFieldIds` in order) and read (`fields` of the view; a
490
+ roadmap without configured columns gives the default ones).
453
491
  - **A project that has been used** — it has tasks.
454
492
 
455
493
  ## Task and PR commands — details
@@ -57,13 +57,27 @@ type Any = any;
57
57
  */
58
58
  let N: Names = locale.names(locale.projectLanguage());
59
59
 
60
- const canonViews = () => [
61
- { name: N.views.board, layout: "BOARD_LAYOUT" },
62
- { name: N.views.table, layout: "TABLE_LAYOUT" },
60
+ /**
61
+ * Views as in the template: columns by `columnKey` in the template's order, the table grouped by Status. The roadmap's
62
+ * columns aren't compared: the API gives it the default ones, it has no configured columns.
63
+ */
64
+ interface CanonView {
65
+ name: string;
66
+ layout: string;
67
+ columns?: string[];
68
+ groupBy?: string;
69
+ }
70
+ const canonViews = (): CanonView[] => [
71
+ { name: N.views.board, layout: "BOARD_LAYOUT", columns: ["TITLE", STATUS, "LABELS", "SUB_ISSUES_PROGRESS"] },
72
+ { name: N.views.table, layout: "TABLE_LAYOUT", columns: ["TITLE", STATUS, "LABELS", "ASSIGNEES", "SUB_ISSUES_PROGRESS", N.fields.estimate], groupBy: STATUS },
63
73
  { name: N.views.roadmap, layout: "ROADMAP_LAYOUT" },
64
74
  ];
75
+ /** A built-in column's name when the project has no such field yet (a column key is its type). */
76
+ const BUILTIN_COLUMNS: Record<string, string> = { TITLE: "Title", ASSIGNEES: "Assignees", LABELS: "Labels", SUB_ISSUES_PROGRESS: "Sub-issues progress" };
65
77
  export const STATUS = "Status";
66
78
  const statusOptions = () => [N.status.backlog, N.status.inProgress, N.status.done];
79
+ /** The template's colors of the Status options, in order — GitHub's colors of a new project's Todo / In Progress / Done. */
80
+ const STATUS_COLORS = ["GREEN", "YELLOW", "PURPLE"];
67
81
  const numberFields = () => [N.fields.estimate, N.fields.actual, N.fields.tokens, N.fields.cost];
68
82
  const issueTypes = () => [N.issueTypes.task, N.issueTypes.bug, N.issueTypes.epic];
69
83
  export const PRIORITY = "Priority";
@@ -98,6 +112,14 @@ const ISSUE_LABEL_LIMIT = 100;
98
112
  export const WORKFLOW_ADDED = "Item added to project";
99
113
  export const WORKFLOW_CLOSED = "Item closed";
100
114
  export const WORKFLOW_AUTO_ADD = "Auto-add to project";
115
+ const WORKFLOW_MERGED = "Pull request merged";
116
+ const WORKFLOW_AUTO_CLOSE = "Auto-close issue";
117
+ const WORKFLOW_SUB_ISSUES = "Auto-add sub-issues to project";
118
+ const WORKFLOW_ARCHIVE = "Auto-archive items";
119
+ /** The template's workflows, all on. */
120
+ const WORKFLOWS = [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_MERGED, WORKFLOW_AUTO_CLOSE, WORKFLOW_AUTO_ADD, WORKFLOW_SUB_ISSUES, WORKFLOW_ARCHIVE];
121
+ /** The template's auto-archive filter: a task closed and untouched for two weeks leaves the board and the table. */
122
+ const ARCHIVE_FILTER = "is:issue is:closed updated:<@today-2w";
101
123
  /** The template: a new project is its copy (views, fields, configured workflows except auto-add). */
102
124
  export const DEFAULT_TEMPLATE = "miroshnik/6";
103
125
  /**
@@ -203,6 +225,9 @@ interface View {
203
225
  filter: string | null;
204
226
  sortBy: string[];
205
227
  columnsBy: string[];
228
+ groupBy: string[];
229
+ /** The visible fields (`fields` of the view). */
230
+ columns: { id: string; name: string; dataType: string }[];
206
231
  }
207
232
  interface Option {
208
233
  id: string;
@@ -388,6 +413,8 @@ export const Q = {
388
413
  id number name layout filter
389
414
  sortByFields(first: 5) { nodes { direction field { ... on ProjectV2FieldCommon { name } } } }
390
415
  verticalGroupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
416
+ groupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
417
+ fields(first: 50) { nodes { ... on ProjectV2FieldCommon { id name dataType } } }
391
418
  }
392
419
  }
393
420
  fields(first: 100) {
@@ -1014,6 +1041,8 @@ function loadProject(io: Io, id: string, withItems = true): Project {
1014
1041
  filter: v.filter ?? null,
1015
1042
  sortBy: (v.sortByFields?.nodes ?? []).map((s: Any) => s.field?.name).filter(Boolean),
1016
1043
  columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f: Any) => f?.name).filter(Boolean),
1044
+ groupBy: (v.groupByFields?.nodes ?? []).map((f: Any) => f?.name).filter(Boolean),
1045
+ columns: (v.fields?.nodes ?? []).filter((f: Any) => f?.id).map((f: Any) => ({ id: f.id, name: f.name, dataType: f.dataType })),
1017
1046
  })),
1018
1047
  fields: p.fields.nodes
1019
1048
  .filter((f: Any) => f && f.id)
@@ -1066,8 +1095,17 @@ export function analyze(s: State): Check[] {
1066
1095
  link(`another project is linked: ${q(other.title)} (${other.url})`, { kind: "confirm", text: `unlink project ${q(other.title)} from the repository`, mutations: [{ op: "UnlinkProject", input: { projectId: other.id, repositoryId: s.repo.id } }] });
1067
1096
  }
1068
1097
 
1098
+ // Columns: a built-in field by its type, the rest by name; a numeric field in another dictionary language is the same
1099
+ // column — the Fields item renames it keeping the id.
1100
+ const fieldAlias = fieldAliases();
1101
+ const columnKey = (f: { name: string; dataType: string }) => (BUILTIN_FIELDS.has(f.dataType) ? f.dataType : (fieldAlias[f.name] ?? f.name));
1102
+ const columnName = (key: string) => p.fields.find((f) => columnKey(f) === key)?.name ?? BUILTIN_COLUMNS[key] ?? key;
1103
+ // a field the project doesn't have yet is skipped: the step's text changes when it appears, and the next round sets it
1104
+ const columnFields = (keys: string[]) => keys.map((k) => p.fields.find((f) => columnKey(f) === k)).filter((f): f is Field => !!f);
1105
+ const configuration = (keys: string[]) => ({ visibleFieldIds: columnFields(keys).map((f) => f.id) });
1106
+
1069
1107
  // Views: by name; missing — rename an extra one of the same layout or create one; extra ones — delete.
1070
- const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, no filter, no others`);
1108
+ const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, columns and grouping, no filter, no others`);
1071
1109
  const canonNames = new Set<string>(canonViews().map((v) => v.name));
1072
1110
  const spare = p.views.filter((v) => !canonNames.has(v.name));
1073
1111
  for (const c of canonViews()) {
@@ -1078,7 +1116,8 @@ export function analyze(s: State): Check[] {
1078
1116
  spare.splice(spare.indexOf(cand), 1);
1079
1117
  views(`missing ${q(c.name)}`, guarded(`rename view ${q(cand.name)} → ${q(c.name)}`, { op: "UpdateView", input: { viewId: cand.id, name: c.name } }));
1080
1118
  } else {
1081
- views(`missing ${q(c.name)}`, api(`create view ${q(c.name)} (${c.layout})`, { op: "CreateView", input: { projectId: p.id, name: c.name, layout: c.layout } }));
1119
+ const input = { projectId: p.id, name: c.name, layout: c.layout, ...(c.columns ? { configuration: configuration(c.columns) } : {}) };
1120
+ views(`missing ${q(c.name)}`, api(`create view ${q(c.name)} (${c.layout})`, { op: "CreateView", input }));
1082
1121
  }
1083
1122
  continue;
1084
1123
  }
@@ -1087,14 +1126,29 @@ export function analyze(s: State): Check[] {
1087
1126
  if (c.layout === "BOARD_LAYOUT" && v.layout === c.layout && v.columnsBy[0] !== STATUS) {
1088
1127
  views(`${q(c.name)} columns not by Status`, ui(`${q(c.name)}: View options → Column by → Status → Save`, `${p.url}/views/${v.number}`));
1089
1128
  }
1129
+ if (c.columns) {
1130
+ const have = v.columns.map(columnKey);
1131
+ const missing = c.columns.filter((k) => !have.includes(k)).map((k) => q(columnName(k)));
1132
+ const extra = v.columns.filter((f) => !c.columns!.includes(columnKey(f))).map((f) => q(f.name));
1133
+ if (missing.length || extra.length) {
1134
+ const why = [missing.length ? `missing ${missing.join(", ")}` : "", extra.length ? `extra ${extra.join(", ")}` : ""].filter(Boolean).join("; ");
1135
+ const set = columnFields(c.columns).map((f) => f.name).join(", ");
1136
+ views(`${q(c.name)} columns: ${why}`, api(`${q(c.name)}: columns ${set}`, { op: "UpdateView", input: { viewId: v.id, configuration: configuration(c.columns) } }));
1137
+ }
1138
+ }
1139
+ // the API reads the grouping but doesn't set it
1140
+ if (c.groupBy && v.layout === c.layout && v.groupBy[0] !== c.groupBy) {
1141
+ views(`${q(c.name)} not grouped by ${c.groupBy}`, ui(`${q(c.name)}: View options → Group by → ${c.groupBy} → Save`, `${p.url}/views/${v.number}`));
1142
+ }
1090
1143
  }
1091
1144
  for (const v of spare) views(`extra ${q(v.name)}`, guarded(`delete view ${q(v.name)}`, { op: "DeleteView", input: { viewId: v.id } }));
1092
1145
 
1093
- // Status: options by name or by a known English name — with the same id; missing ones — new.
1094
- const status = add("status", `${STATUS}: ${statusOptions().join(" → ")}`);
1146
+ // Status: options by name or by a known English name — with the same id; missing ones — new; colors — the template's,
1147
+ // descriptions are the project's own.
1148
+ const status = add("status", `${STATUS}: ${statusOptions().join(" → ")} (${STATUS_COLORS.join(", ")})`);
1095
1149
  const sf = p.fields.find((f) => f.name === STATUS && f.options);
1096
1150
  if (!sf) {
1097
- status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name) => ({ name, color: "GRAY", description: "" })) } }));
1151
+ status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name, i) => ({ name, color: STATUS_COLORS[i], description: "" })) } }));
1098
1152
  } else if (sf.options!.map((o) => o.name).join("\n") !== statusOptions().join("\n")) {
1099
1153
  const left = [...sf.options!];
1100
1154
  const take = (pred: (o: Option) => boolean) => {
@@ -1102,18 +1156,21 @@ export function analyze(s: State): Check[] {
1102
1156
  return i < 0 ? null : left.splice(i, 1)[0]!;
1103
1157
  };
1104
1158
  const aliases = statusAliases();
1105
- const next = statusOptions().map((name) => {
1159
+ const next = statusOptions().map((name, i) => {
1106
1160
  const o = take((x) => x.name === name) ?? take((x) => aliases[x.name.toLowerCase()] === name);
1107
- return o ? { id: o.id, name, color: o.color, description: o.description ?? "" } : { name, color: "GRAY", description: "" };
1161
+ return o ? { id: o.id, name, color: STATUS_COLORS[i]!, description: o.description ?? "" } : { name, color: STATUS_COLORS[i]!, description: "" };
1108
1162
  });
1109
1163
  const drop = left.length ? `; delete ${left.map((o) => q(o.name)).join(", ")}` : "";
1110
1164
  status(`options: ${sf.options!.map((o) => o.name).join(", ") || "none"}`, guarded(`${STATUS}: ${statusOptions().join(", ")} (rename the existing ones keeping the id${drop})`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
1165
+ } else if (sf.options!.some((o, i) => o.color !== STATUS_COLORS[i])) {
1166
+ // recoloring keeps the ids — the tasks' values and the workflows' targets, no confirmation needed
1167
+ const next = sf.options!.map((o, i) => ({ id: o.id, name: o.name, color: STATUS_COLORS[i]!, description: o.description ?? "" }));
1168
+ status(`colors: ${sf.options!.map((o) => `${o.name} ${o.color}`).join(", ")}`, api(`${STATUS}: colors ${STATUS_COLORS.join(", ")} (keeping the id)`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
1111
1169
  }
1112
1170
 
1113
1171
  // Fields: four numeric ones; a field in another dictionary language is renamed with the same id (the tasks' values are
1114
1172
  // kept); no custom fields beyond the canon; in an organization Priority is an issue field.
1115
1173
  const fields = add("fields", `Fields ${numberFields().map(q).join(", ")} (Number), no other custom ones`);
1116
- const fieldAlias = fieldAliases();
1117
1174
  const renamed = new Set<Field>();
1118
1175
  for (const name of numberFields()) {
1119
1176
  const f = p.fields.find((x) => x.name === name);
@@ -1177,14 +1234,18 @@ export function analyze(s: State): Check[] {
1177
1234
  }
1178
1235
 
1179
1236
  // Workflows: the API doesn't turn them on or show the target — only the UI. An unconfigured one isn't in the list.
1180
- const wf = add("workflows", `Workflows ${[WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD].map(q).join(", ")} are on`);
1237
+ const wf = add("workflows", `Workflows ${WORKFLOWS.map(q).join(", ")} are on`);
1181
1238
  const how: Record<string, string> = {
1182
1239
  [WORKFLOW_ADDED]: `Edit → Set value → ${STATUS}: ${N.status.backlog}`,
1183
1240
  [WORKFLOW_CLOSED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
1241
+ [WORKFLOW_MERGED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
1242
+ [WORKFLOW_AUTO_CLOSE]: `Edit → ${STATUS}: ${N.status.done}`,
1184
1243
  // is:open — otherwise an updated closed task (not planned too) comes back into the project
1185
1244
  [WORKFLOW_AUTO_ADD]: `Edit → repository ${s.repo.name}, filter is:issue is:open (Enter)`,
1245
+ [WORKFLOW_SUB_ISSUES]: "Edit",
1246
+ [WORKFLOW_ARCHIVE]: `Edit → filter ${ARCHIVE_FILTER} (Enter)`,
1186
1247
  };
1187
- for (const name of [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD]) {
1248
+ for (const name of WORKFLOWS) {
1188
1249
  const w = p.workflows.find((x) => x.name === name);
1189
1250
  if (w?.enabled) continue;
1190
1251
  // an unconfigured workflow isn't in the API — a link to the list, it's in the menu on the left there
@@ -2354,7 +2415,7 @@ function cloudBrief(io: Io, slug: string, issue: Issue): void {
2354
2415
  state: N.comments.state,
2355
2416
  questions: N.sections.questions,
2356
2417
  };
2357
- for (const line of [N.queue.cloud, N.queue.cloudDoc, N.queue.cloudClose, N.queue.cloudDispatcher, N.queue.cloudQuestion]) io.out(locale.fill(line, vars));
2418
+ for (const line of [N.queue.cloud, N.queue.cloudDoc, N.queue.repeats, N.queue.cloudClose, N.queue.cloudDispatcher, N.queue.cloudQuestion]) io.out(locale.fill(line, vars));
2358
2419
  io.out(
2359
2420
  `Next: the lines above — the cloud session's first prompt as is, the session started from the project folder (the cloud starts from main); ` +
2360
2421
  `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: ` +
@@ -2375,6 +2436,17 @@ const SESSION_ARCHIVE =
2375
2436
  "an executor session whose task closed — the dispatcher archives it in an auto project where the agent has the tool, " +
2376
2437
  "not one still working in the background or awaiting the owner's answer; outside auto the owner closes it";
2377
2438
 
2439
+ /**
2440
+ * A task's open PR: linked to it by GitHub, or one of the repository's recent ones by the task branch or `Closes #N` in
2441
+ * its body. The queue's «=» (the executor waits for its CI) and the epic refresh's «already going» (#636).
2442
+ */
2443
+ function openPrOf(io: Io, slug: string): (i: Issue) => number | undefined {
2444
+ const [owner, name] = slug.split("/");
2445
+ const recent = (graphql(io, Q.RecentPrs, { owner, name })?.repository?.pullRequests?.nodes ?? []).filter((p: Any) => p.state === "OPEN");
2446
+ return (i) =>
2447
+ i.prs.find((p) => p.state === "OPEN")?.number ?? recent.find((p: Any) => isTaskBranch(p.headRefName ?? "", i.number) || closingInBody(p.body ?? "").includes(i.number))?.number;
2448
+ }
2449
+
2378
2450
  /**
2379
2451
  * The dispatcher's queue: its ready tasks go to background executors (subagents), K at a time; the queue is re-read
2380
2452
  * after each answer, so tasks that appeared meanwhile are picked up. A session with its own task dispatches too (#546:
@@ -2405,10 +2477,7 @@ export function cmdTaskQueue(io: Io, slug: string, o: QueueFilter): number {
2405
2477
  if (found.length >= 100) io.out("○ the search gave 100 open tasks — the queue sees only them: narrow it with --epic");
2406
2478
  numbers = found.filter((x) => x?.state === "OPEN").map((x) => x.number);
2407
2479
  }
2408
- const [owner, name] = slug.split("/");
2409
- const recent = (graphql(io, Q.RecentPrs, { owner, name })?.repository?.pullRequests?.nodes ?? []).filter((p: Any) => p.state === "OPEN");
2410
- const openPr = (i: Issue) =>
2411
- i.prs.find((p) => p.state === "OPEN")?.number ?? recent.find((p: Any) => isTaskBranch(p.headRefName ?? "", i.number) || closingInBody(p.body ?? "").includes(i.number))?.number;
2480
+ const openPr = openPrOf(io, slug);
2412
2481
  const handed = handedOut(io, slug);
2413
2482
  /** Issue states the queue has read; a hand-out's task outside the queue is read when its worktree is up for removal. */
2414
2483
  const states = new Map<number, string>();
@@ -2869,8 +2938,8 @@ export function cmdTaskAssignment(io: Io, slug: string, number: number, o: { clo
2869
2938
  const resume = restart.length ? [N.queue.resume] : [];
2870
2939
  // the epic refresh in the same turn (`refresh`, #628) — the subagent's: a session runs it by the canon, with the deploy's wait
2871
2940
  const lines = o.session
2872
- ? [N.queue.sessionLead, ...resume, N.queue.sessionIs, N.queue.sessionName, N.queue.sessionWait, N.queue.sessionFound, N.queue.sessionQuestion, N.queue.sessionEnd]
2873
- : [N.queue.lead, ...resume, N.queue.subagent, N.queue.session, N.queue.wait, N.queue.git, N.queue.subagents, N.queue.live, N.queue.refresh, N.queue.found, N.queue.question, N.queue.answer];
2941
+ ? [N.queue.sessionLead, ...resume, N.queue.sessionIs, N.queue.sessionName, N.queue.sessionWait, N.queue.repeats, N.queue.sessionFound, N.queue.sessionQuestion, N.queue.sessionEnd]
2942
+ : [N.queue.lead, ...resume, N.queue.subagent, N.queue.session, N.queue.wait, N.queue.git, N.queue.subagents, N.queue.live, N.queue.refresh, N.queue.repeats, N.queue.found, N.queue.question, N.queue.answer];
2874
2943
  for (const line of lines) io.out(locale.fill(line, vars));
2875
2944
  if (o.session) {
2876
2945
  io.out(
@@ -3063,8 +3132,9 @@ const textBlock = (body: string) => (body ? `~~~~markdown\n${body}\n~~~~` : N.ac
3063
3132
  * its open tasks and the PR files run to tens of KB, and the session at peak context would copy them into the subagent
3064
3133
  * call as output tokens. In it — the task and its PR with its files, the epic and its open tasks with their texts, the
3065
3134
  * copies of links and the answers in comments the script found, the checks by meaning quoted from reference.md as is,
3066
- * what may be changed and the answer form; in the project language (the `actualize` keys). No epic, or no open tasks in
3067
- * it besides this one — the checks by meaning have nothing to go through: a "not needed" line, no subagent.
3135
+ * what may be changed and the answer form; under each open task — where a repeat finding of check 5 goes, by its Status
3136
+ * and open PR (#636); in the project language (the `actualize` keys). No epic, or no open tasks in it besides this one
3137
+ * — the checks by meaning have nothing to go through: a "not needed" line, no subagent.
3068
3138
  */
3069
3139
  export function cmdTaskActualize(io: Io, slug: string, number: number, o: { assignment: boolean } = { assignment: false }): number {
3070
3140
  const issue = loadIssue(io, slug, number);
@@ -3076,6 +3146,8 @@ export function cmdTaskActualize(io: Io, slug: string, number: number, o: { assi
3076
3146
  const open = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).map((x) => loadTaskText(io, slug, x.number));
3077
3147
  if (!o.assignment) refreshMechanics(io, slug, { number, merged: prs.length > 0, epic, open });
3078
3148
  if (!open.length) return io.out(`○ refresh not needed: epic #${epic.number} has no open tasks besides #${number}. ${later}`), 0;
3149
+ // the board with its Status (and its language) — the assignment names where a repeat finding goes (#636)
3150
+ const ctx = o.assignment ? taskContext(io, slug) : null;
3079
3151
  const vars = { task: N.subagentPrompt.task, epicWord: N.subagentPrompt.epic, number, epic: epic.number, repo: slug };
3080
3152
  const lead = locale.fill(N.actualize.lead, vars);
3081
3153
  if (!o.assignment) {
@@ -3094,7 +3166,17 @@ export function cmdTaskActualize(io: Io, slug: string, number: number, o: { assi
3094
3166
  if (files.length) lines.push(locale.fill(N.actualize.files, { pr: p.number }), ...files.map(fileLine));
3095
3167
  }
3096
3168
  lines.push("", locale.fill(N.actualize.epic, { ...vars, title: epic.title }), textBlock(epic.body), "", N.actualize.openTasks);
3097
- for (const t of open) lines.push("", locale.fill(N.actualize.openTask, { number: t.number, title: t.title }), textBlock(t.body));
3169
+ const openPr = openPrOf(io, slug);
3170
+ /** Where a repeat finding of check 5 goes (#636): a task in Backlog takes it as an item, one already going doesn't. */
3171
+ const target = (n: number): string[] => {
3172
+ const i = loadIssue(io, slug, n);
3173
+ const status = itemIn(ctx!, i)?.status ?? null;
3174
+ const pr = openPr(i);
3175
+ const work = [status === N.status.inProgress ? locale.fill(N.actualize.taskStatus, { status }) : "", pr === undefined ? "" : locale.fill(N.actualize.taskPr, { pr })].filter(Boolean);
3176
+ if (work.length) return [locale.fill(N.actualize.inWork, { work: work.join(", ") })];
3177
+ return status === N.status.backlog ? [locale.fill(N.actualize.backlog, { status: locale.fill(N.actualize.taskStatus, { status }) })] : [];
3178
+ };
3179
+ for (const t of open) lines.push("", locale.fill(N.actualize.openTask, { number: t.number, title: t.title }), ...target(t.number), textBlock(t.body));
3098
3180
  const copies = linkCopies(epic, open);
3099
3181
  if (copies.length) lines.push("", N.actualize.copies, ...copies.map((c) => locale.fill(N.actualize.copy, c)));
3100
3182
  const answers = commentAnswers([epic, ...open]);
@@ -73,6 +73,14 @@ export interface Names {
73
73
  epic: string;
74
74
  openTasks: string;
75
75
  openTask: string;
76
+ /**
77
+ * Where a repeat finding of check 5 goes, under an open task's heading (#636): a task in Backlog takes it as an item
78
+ * (`backlog`), one already going — In progress or with an open PR — doesn't (`inWork`); `taskStatus`, `taskPr` — why.
79
+ */
80
+ backlog: string;
81
+ inWork: string;
82
+ taskStatus: string;
83
+ taskPr: string;
76
84
  noBody: string;
77
85
  copies: string;
78
86
  copy: string;
@@ -104,6 +112,8 @@ export interface Names {
104
112
  live: string;
105
113
  /** The epic refresh — in the same turn, before `task close` and the answer; a task without a PR closed first (#628). */
106
114
  refresh: string;
115
+ /** Repeats in the files the task changes anyway — fixed in its PR, not a new task (#636); every kind of executor. */
116
+ repeats: string;
107
117
  found: string;
108
118
  question: string;
109
119
  answer: string;