@miroshnik/elves 2026.1019.0 → 2026.1019.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
  /**
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miroshnik/elves",
3
- "version": "2026.1019.0",
3
+ "version": "2026.1019.1",
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": "672760816ee853bd6c6cedf7bd917a123ee0fb41"
75
75
  }
@@ -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
@@ -313,16 +325,23 @@ description is rewritten.
313
325
 
314
326
  1. The project is linked to the repository, it is the only one, its title =
315
327
  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`.
328
+ 2. Views `Board` (BOARD, columns by `Status`), `Table` (TABLE, grouped by
329
+ `Status`), `Roadmap` (ROADMAP), filters empty, no others; the columns of
330
+ `Board` and `Table` — the canon's set (a built-in field by its type, the
331
+ rest by name), `fix` sets them in the template's order. The roadmap's
332
+ columns aren't compared: the API gives it the default ones.
333
+ 3. `Status`: `Backlog` → `In progress` → `Done`, colors `GREEN`, `YELLOW`,
334
+ `PURPLE`; descriptions aren't compared.
319
335
  4. Fields «Estimate, h», «Actual, h», «Tokens, M», «Cost, $» (Number), no
320
336
  custom fields beyond the canon.
321
337
  5. Organization only: `Priority` — the organization's issue field, connected,
322
338
  `Table` and `Board` are sorted by it; issue types — exactly «Task»,
323
339
  «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.
340
+ 6. The template's workflows are on: "Item added to project" (→ Backlog),
341
+ "Item closed" and "Pull request merged" (→ Done), "Auto-close issue",
342
+ "Auto-add to project" (`is:issue is:open`), "Auto-add sub-issues to
343
+ project", "Auto-archive items" (`is:issue is:closed updated:<@today-2w`).
344
+ Others aren't checked.
326
345
  7. Closed tasks — in Done; those closed without being done (not planned,
327
346
  duplicate) — outside the project.
328
347
  8. The repository's open tasks — in the project, each has a `Status`.
@@ -379,7 +398,8 @@ description is rewritten.
379
398
  ### fix: who does what
380
399
 
381
400
  - **API — the script itself:** linking, a template copy or a new project,
382
- missing views and fields, view layout, removing a filter, `Status` options,
401
+ missing views and fields, view layout and columns, removing a filter,
402
+ `Status` options and colors,
383
403
  the `Priority` issue field, task statuses, adding open tasks, removing tasks
384
404
  closed without being done from the project, decision labels (creation,
385
405
  color), the default-branch ruleset (creation, editing its own), merge
@@ -409,14 +429,19 @@ description is rewritten.
409
429
  deleted (options were replaced without ids): pick it again.
410
430
  - "Item added to project": trigger `issue, pull request` (default), Set
411
431
  value → `Status: Backlog`.
412
- - "Item closed": Set value → `Status: Done`.
432
+ - "Item closed", "Pull request merged": Set value → `Status: Done`.
433
+ - "Auto-close issue": `Status: Done` → close the issue.
413
434
  - "Auto-add to project": the repository — this project's, filter
414
435
  `is:issue is:open`, Enter — the "See N existing items" counter should
415
436
  update. Without `is:open` an updated closed task (a not planned one too)
416
437
  comes back into the project. Auto-add doesn't add already existing tasks —
417
438
  `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
439
+ - "Auto-add sub-issues to project": nothing to set — Save and turn on.
440
+ - "Auto-archive items": filter `is:issue is:closed updated:<@today-2w`,
441
+ Enter.
442
+ - **Sorting by Priority, board columns, table grouping:** the view → the
443
+ `View` button on the right → `Sort by: Priority` / `Column by: Status` /
444
+ `Group by: Status` → `Save view` → the dialog
420
445
  "Save display options for …?" → `Save`. Escape closes the dialog without
421
446
  saving: the sorting stays only in the URL (`?sortedBy…`), and the tab's
422
447
  unsaved marker lies — `check` verifies the result.
@@ -435,8 +460,9 @@ description is rewritten.
435
460
  Done and leaves a step to check the target in the UI.
436
461
  - **`Status` options are renamed keeping the same id**
437
462
  (`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`.
463
+ "Item closed" target are kept, recoloring too. Replacing options without
464
+ ids gives new ids — task statuses vanish, "Item closed" sets the deleted
465
+ `Done`.
440
466
  - **The template** — the elves project (`miroshnik/6`): everything the API
441
467
  lacks is configured there once. A copy (`copyProjectV2`) carries over views
442
468
  with their settings, fields and configured workflows, except auto-add; it
@@ -444,12 +470,16 @@ description is rewritten.
444
470
  is unavailable (someone else's account) or `--template none` — the project is
445
471
  created from scratch (`createProjectV2`) and brought up via the API:
446
472
  `Todo / In Progress / Done` → the canon with the same ids, `View 1` →
447
- `Table`, the rest is created.
473
+ `Table`, the rest is created; the table's grouping is a UI step.
448
474
  - **The view filter is empty:** `iteration:@current` without an Iteration field
449
475
  silently hides tasks from the board.
450
476
  - **`Priority` is the organization's issue field** (`isIssueField`), not a
451
477
  project field of the same name. The API reads the sorting (`sortByFields`)
452
- but doesn't set it; the board grouping too (`verticalGroupByFields`).
478
+ but doesn't set it; the board grouping and the table grouping too
479
+ (`verticalGroupByFields`, `groupByFields`).
480
+ - **A view's columns are set** (`updateProjectV2View`,
481
+ `configuration.visibleFieldIds` in order) and read (`fields` of the view; a
482
+ roadmap without configured columns gives the default ones).
453
483
  - **A project that has been used** — it has tasks.
454
484
 
455
485
  ## 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