itsvertical 0.0.2 → 0.0.4

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
@@ -2,7 +2,9 @@
2
2
 
3
3
  **Tickets pile up, scopes get done. Project work isn't linear, it's Vertical.**
4
4
 
5
- Vertical is a file-based project management tool built around the nine-box grid. No accounts, no cloud, no setup. Just a `.vertical` file and your terminal.
5
+ Vertical is a file-based project management tool that organizes work into vertical slices that can each be completed independently. No accounts, no cloud, no setup. Just a `.vertical` file and your terminal.
6
+
7
+ ![Vertical board](app/images/screenshot.png)
6
8
 
7
9
  ```
8
10
  npx itsvertical new my-project.vertical "My Project"
@@ -10,13 +12,13 @@ npx itsvertical new my-project.vertical "My Project"
10
12
 
11
13
  ## Built for AI agents
12
14
 
13
- Vertical is designed to be used through AI coding agents like [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview). The CLI is the primary interface — every entity is addressed by ID, every command accepts `--json` for structured output, and errors are machine-readable. An agent can create a project, break work into scopes, add tasks, and track progress — all through the command line.
15
+ Vertical is designed to be used through AI coding agents. The CLI is the primary interface — every entity is addressed by ID, every command accepts `--json` for structured output, and errors are machine-readable. An agent can create a project, break work into slices, add tasks, and track progress — all through the command line.
14
16
 
15
17
  The browser UI (`itsvertical open`) is there for when you want to see the board visually, drag things around, or get a quick overview.
16
18
 
17
19
  ## How it works
18
20
 
19
- Vertical organizes work into a 3x3 grid of boxes. Each box can hold tasks, and each box can be split into layers (steps). Tasks move between boxes, layers break work into phases, and things get marked done as you go.
21
+ Each slice is a box on the board. Add tasks to a box, and split it into layers when the work has distinct phases design then build, for example. Slices get completed independently. Layers break the work within a slice into steps.
20
22
 
21
23
  Everything is saved to a single `.vertical` file. Version it with git, share it with teammates, or let your agent manage it.
22
24
 
@@ -54,6 +56,7 @@ itsvertical open <file> # Open in the browser UI
54
56
  itsvertical show <file> # Print the board to the terminal
55
57
  itsvertical show <file> --json # Output the board as JSON
56
58
  itsvertical show <file> --box <slice-id> # Show only a specific box
59
+ itsvertical show <file> --visual # Show the board as a visual 3x3 grid with summary
57
60
  itsvertical rename <file> <name> # Rename the project
58
61
  ```
59
62
 
@@ -92,9 +95,9 @@ itsvertical layer status <file> <layer-id> none # Clear status
92
95
 
93
96
  `itsvertical open` starts a local server and opens the board in your browser. Click **Save** or press **Ctrl+S** / **Cmd+S** to write changes back to the file.
94
97
 
95
- ## The nine-box grid
98
+ ## The board
96
99
 
97
- The board is a 3x3 grid. Each box represents a scope of work.
100
+ Each box represents a vertical slice of work.
98
101
 
99
102
  - **Name boxes** by clicking the title area
100
103
  - **Drag boxes** to rearrange them in the grid
@@ -139,7 +142,7 @@ Source at [github.com/seasonedcc/vertical](https://github.com/seasonedcc/vertica
139
142
 
140
143
  The package has two parts:
141
144
 
142
- - **SPA** (`app/`) — A React app built with Vite. This is the nine-box board UI. Built to `dist/`.
145
+ - **SPA** (`app/`) — A React app built with Vite. The board UI. Built to `dist/`.
143
146
  - **CLI** (`cli/`) — A Node.js CLI built with tsup. Starts a local HTTP server that serves the SPA and provides a read/write API for the `.vertical` file. Built to `cli/dist/`.
144
147
 
145
148
  The CLI and SPA share code: types (`app/state/types.ts`), serialization (`app/file/format.ts`), and project creation (`app/state/initial-state.ts`).
package/cli/dist/index.js CHANGED
@@ -30,7 +30,7 @@ function deserialize(json) {
30
30
  project: file.project,
31
31
  slices: file.slices,
32
32
  layers: file.layers,
33
- tasks: file.tasks
33
+ tasks: file.tasks.map((t) => ({ ...t, notesHtml: t.notesHtml ?? null }))
34
34
  };
35
35
  }
36
36
 
@@ -81,7 +81,8 @@ function boardReducer(state, action) {
81
81
  layerId: action.layerId,
82
82
  name: action.name,
83
83
  sorting: action.sorting,
84
- done: false
84
+ done: false,
85
+ notesHtml: null
85
86
  }
86
87
  ]
87
88
  };
@@ -103,7 +104,8 @@ function boardReducer(state, action) {
103
104
  layerId: afterTask.layerId,
104
105
  name: "",
105
106
  sorting,
106
- done: false
107
+ done: false,
108
+ notesHtml: null
107
109
  }
108
110
  ]
109
111
  };
@@ -201,6 +203,13 @@ function boardReducer(state, action) {
201
203
  tasks: updatedTasks
202
204
  };
203
205
  }
206
+ case "SET_TASK_NOTES":
207
+ return {
208
+ ...state,
209
+ tasks: state.tasks.map(
210
+ (t) => t.id === action.taskId ? { ...t, notesHtml: action.notesHtml } : t
211
+ )
212
+ };
204
213
  case "RENAME_SLICE":
205
214
  return {
206
215
  ...state,
@@ -1869,7 +1878,8 @@ function showBoard(state, boxId) {
1869
1878
  } else {
1870
1879
  for (const task2 of layerTasks) {
1871
1880
  const check = task2.done ? "x" : " ";
1872
- console.log(` [${check}] ${task2.name} (id: ${task2.id})`);
1881
+ const notesTag = task2.notesHtml ? " [notes]" : "";
1882
+ console.log(` [${check}] ${task2.name}${notesTag} (id: ${task2.id})`);
1873
1883
  }
1874
1884
  }
1875
1885
  }
@@ -1921,6 +1931,301 @@ function fail(message, json) {
1921
1931
  process.exit(1);
1922
1932
  }
1923
1933
 
1934
+ // cli/board.ts
1935
+ var MIN_COL_WIDTH = 25;
1936
+ var MAX_COL_WIDTH = 40;
1937
+ var PADDING = 2;
1938
+ var ansi = {
1939
+ reset: "\x1B[0m",
1940
+ bold: "\x1B[1m",
1941
+ dim: "\x1B[2m",
1942
+ green: "\x1B[32m",
1943
+ yellow: "\x1B[33m",
1944
+ cyan: "\x1B[36m"
1945
+ };
1946
+ function style(text, ...codes) {
1947
+ return `${codes.join("")}${text}${ansi.reset}`;
1948
+ }
1949
+ var ANSI_PATTERN = new RegExp(`${"\x1B"}\\[[0-9;]*m`, "g");
1950
+ function stripAnsi(text) {
1951
+ return text.replace(ANSI_PATTERN, "");
1952
+ }
1953
+ function visibleLength(text) {
1954
+ return stripAnsi(text).length;
1955
+ }
1956
+ function truncateStyled(text, maxWidth) {
1957
+ if (visibleLength(text) <= maxWidth)
1958
+ return text;
1959
+ let visible = 0;
1960
+ let i = 0;
1961
+ while (i < text.length && visible < maxWidth - 1) {
1962
+ if (text[i] === "\x1B") {
1963
+ const end = text.indexOf("m", i);
1964
+ i = end + 1;
1965
+ continue;
1966
+ }
1967
+ visible++;
1968
+ i++;
1969
+ }
1970
+ return `${text.slice(0, i)}${ansi.reset}\u2026`;
1971
+ }
1972
+ function padRightStyled(text, width) {
1973
+ const padding = Math.max(0, width - visibleLength(text));
1974
+ return text + " ".repeat(padding);
1975
+ }
1976
+ function getSliceLayers(state, slice) {
1977
+ return sortBy_default(
1978
+ state.layers.filter((l) => l.sliceId === slice.id),
1979
+ "sorting"
1980
+ );
1981
+ }
1982
+ function getLayerTasks(state, layer2) {
1983
+ return sortBy_default(
1984
+ state.tasks.filter((t) => t.layerId === layer2.id),
1985
+ "sorting"
1986
+ );
1987
+ }
1988
+ function getSliceTasks(state, slice) {
1989
+ const layers = getSliceLayers(state, slice);
1990
+ return layers.flatMap((l) => getLayerTasks(state, l));
1991
+ }
1992
+ function buildCellLines(state, slice, innerWidth) {
1993
+ const lines = [];
1994
+ const layers = getSliceLayers(state, slice);
1995
+ const allTasks = getSliceTasks(state, slice);
1996
+ const hasMultipleLayers = layers.length > 1;
1997
+ const separator = style("\u2504".repeat(innerWidth), ansi.dim);
1998
+ if (slice.name) {
1999
+ lines.push(style(slice.name, ansi.bold, ansi.cyan));
2000
+ }
2001
+ if (allTasks.length === 0) {
2002
+ lines.push(style("(no tasks)", ansi.dim));
2003
+ return lines;
2004
+ }
2005
+ for (let i = 0; i < layers.length; i++) {
2006
+ const layer2 = layers[i];
2007
+ const tasks = getLayerTasks(state, layer2);
2008
+ if (hasMultipleLayers && i > 0) {
2009
+ lines.push(separator);
2010
+ }
2011
+ if (hasMultipleLayers) {
2012
+ const layerName = layer2.name ?? `Layer ${i + 1}`;
2013
+ if (layer2.status === "done") {
2014
+ lines.push(style(`\u2713 ${layerName} (done)`, ansi.green));
2015
+ } else {
2016
+ lines.push(style(layerName, ansi.bold));
2017
+ }
2018
+ }
2019
+ for (const task2 of tasks) {
2020
+ if (task2.done) {
2021
+ lines.push(` ${style("\u25CF", ansi.green)} ${style(task2.name, ansi.dim)}`);
2022
+ } else {
2023
+ lines.push(` ${style("\u25CB", ansi.yellow)} ${task2.name}`);
2024
+ }
2025
+ }
2026
+ }
2027
+ return lines;
2028
+ }
2029
+ function showBoardGrid(state, boxId) {
2030
+ const sortedSlices = sortBy_default(state.slices, "boxNumber");
2031
+ const filteredSlices = boxId ? sortedSlices.filter((s) => s.id === boxId) : sortedSlices;
2032
+ const colWidth = Math.min(
2033
+ MAX_COL_WIDTH,
2034
+ Math.max(
2035
+ MIN_COL_WIDTH,
2036
+ ...filteredSlices.flatMap((slice) => {
2037
+ const layers = getSliceLayers(state, slice);
2038
+ const tasks = getSliceTasks(state, slice);
2039
+ const widths = [
2040
+ slice.name?.length ?? 0,
2041
+ ...layers.map((l) => (l.name?.length ?? 0) + 10),
2042
+ ...tasks.map((t) => t.name.length + 4)
2043
+ ];
2044
+ return widths;
2045
+ }).map((w) => w + PADDING * 2)
2046
+ )
2047
+ );
2048
+ const innerWidth = colWidth - PADDING * 2;
2049
+ const numCols = boxId ? 1 : 3;
2050
+ const cellContents = /* @__PURE__ */ new Map();
2051
+ for (const slice of filteredSlices) {
2052
+ cellContents.set(slice.id, buildCellLines(state, slice, innerWidth));
2053
+ }
2054
+ const rows = [];
2055
+ if (boxId) {
2056
+ rows.push(filteredSlices);
2057
+ } else {
2058
+ rows.push(
2059
+ filteredSlices.filter((s) => s.boxNumber >= 1 && s.boxNumber <= 3)
2060
+ );
2061
+ rows.push(
2062
+ filteredSlices.filter((s) => s.boxNumber >= 4 && s.boxNumber <= 6)
2063
+ );
2064
+ rows.push(
2065
+ filteredSlices.filter((s) => s.boxNumber >= 7 && s.boxNumber <= 9)
2066
+ );
2067
+ }
2068
+ console.log(style(state.project.name, ansi.bold));
2069
+ console.log();
2070
+ const border = (text) => style(text, ansi.dim);
2071
+ const makeBorder = (left, mid2, right) => {
2072
+ const parts = [];
2073
+ for (let c = 0; c < numCols; c++) {
2074
+ parts.push("\u2500".repeat(colWidth));
2075
+ }
2076
+ return border(left + parts.join(mid2) + right);
2077
+ };
2078
+ const top = makeBorder("\u250C", "\u252C", "\u2510");
2079
+ const mid = makeBorder("\u251C", "\u253C", "\u2524");
2080
+ const bot = makeBorder("\u2514", "\u2534", "\u2518");
2081
+ for (let r = 0; r < rows.length; r++) {
2082
+ const row = rows[r];
2083
+ console.log(r === 0 ? top : mid);
2084
+ const cellLines = row.map((slice) => cellContents.get(slice.id) ?? []);
2085
+ const rowHeight = Math.max(3, ...cellLines.map((l) => l.length + 2));
2086
+ for (let line = 0; line < rowHeight; line++) {
2087
+ let output2 = border("\u2502");
2088
+ for (let c = 0; c < numCols; c++) {
2089
+ const lines = cellLines[c] ?? [];
2090
+ const contentLine = line - 1;
2091
+ let content = "";
2092
+ if (contentLine >= 0 && contentLine < lines.length) {
2093
+ content = truncateStyled(lines[contentLine], innerWidth);
2094
+ }
2095
+ output2 += `${" ".repeat(PADDING) + padRightStyled(content, innerWidth) + " ".repeat(PADDING)}${border("\u2502")}`;
2096
+ }
2097
+ console.log(output2);
2098
+ }
2099
+ }
2100
+ console.log(bot);
2101
+ const totalTasks = state.tasks.length;
2102
+ const doneTasks = state.tasks.filter((t) => t.done).length;
2103
+ const progressColor = doneTasks === totalTasks ? ansi.green : ansi.yellow;
2104
+ console.log();
2105
+ console.log(
2106
+ `${style("Progress:", ansi.bold)} ${style(`${doneTasks}/${totalTasks}`, ansi.bold, progressColor)} tasks done`
2107
+ );
2108
+ }
2109
+ function formatGroupLabel(boxNumbers) {
2110
+ if (boxNumbers.length === 0)
2111
+ return "";
2112
+ if (boxNumbers.length === 1)
2113
+ return `Box ${boxNumbers[0]}`;
2114
+ const ranges = [];
2115
+ let start = boxNumbers[0];
2116
+ let end = boxNumbers[0];
2117
+ for (let i = 1; i < boxNumbers.length; i++) {
2118
+ if (boxNumbers[i] === end + 1) {
2119
+ end = boxNumbers[i];
2120
+ } else {
2121
+ ranges.push(start === end ? `${start}` : `${start}\u2013${end}`);
2122
+ start = boxNumbers[i];
2123
+ end = boxNumbers[i];
2124
+ }
2125
+ }
2126
+ ranges.push(start === end ? `${start}` : `${start}\u2013${end}`);
2127
+ return `Boxes ${ranges.join(", ")}`;
2128
+ }
2129
+ function buildStatusText(state, slice) {
2130
+ const layers = getSliceLayers(state, slice);
2131
+ const tasks = getSliceTasks(state, slice);
2132
+ if (tasks.length === 0)
2133
+ return style("No tasks yet", ansi.dim);
2134
+ const doneTasks = tasks.filter((t) => t.done).length;
2135
+ const totalTasks = tasks.length;
2136
+ if (doneTasks === totalTasks)
2137
+ return style("All done", ansi.green);
2138
+ if (layers.length > 1) {
2139
+ const doneLayerNames = layers.filter((l) => l.status === "done").map((l) => {
2140
+ const idx = layers.indexOf(l);
2141
+ return l.name ?? `Layer ${idx + 1}`;
2142
+ });
2143
+ const openCount = totalTasks - doneTasks;
2144
+ const parts = [];
2145
+ if (doneLayerNames.length > 0) {
2146
+ parts.push(`${doneLayerNames.join(", ")} marked done`);
2147
+ }
2148
+ if (openCount > 0) {
2149
+ parts.push(`${openCount} task${openCount === 1 ? "" : "s"} still open`);
2150
+ }
2151
+ return parts.join(", ");
2152
+ }
2153
+ return `${doneTasks}/${totalTasks} tasks done`;
2154
+ }
2155
+ function showSummaryTable(state, boxId) {
2156
+ const sortedSlices = sortBy_default(state.slices, "boxNumber");
2157
+ const filteredSlices = boxId ? sortedSlices.filter((s) => s.id === boxId) : sortedSlices;
2158
+ const summaryRows = [];
2159
+ const unnamedEmpty = [];
2160
+ for (const slice of filteredSlices) {
2161
+ const tasks = getSliceTasks(state, slice);
2162
+ if (!slice.name && tasks.length === 0) {
2163
+ unnamedEmpty.push(slice.boxNumber);
2164
+ continue;
2165
+ }
2166
+ const doneTasks = tasks.filter((t) => t.done).length;
2167
+ const doneColor = tasks.length > 0 && doneTasks === tasks.length ? ansi.green : ansi.yellow;
2168
+ summaryRows.push({
2169
+ scope: style(slice.name ?? `Box ${slice.boxNumber}`, ansi.bold),
2170
+ done: tasks.length > 0 ? style(`${doneTasks}/${tasks.length}`, doneColor) : style("\u2014", ansi.dim),
2171
+ status: buildStatusText(state, slice)
2172
+ });
2173
+ }
2174
+ if (unnamedEmpty.length > 0) {
2175
+ summaryRows.push({
2176
+ scope: style(formatGroupLabel(unnamedEmpty), ansi.dim),
2177
+ done: style("\u2014", ansi.dim),
2178
+ status: style("Unnamed & empty", ansi.dim)
2179
+ });
2180
+ }
2181
+ if (summaryRows.length === 0)
2182
+ return;
2183
+ const headers = {
2184
+ scope: style("Scope", ansi.bold),
2185
+ done: style("Done", ansi.bold),
2186
+ status: style("Status", ansi.bold)
2187
+ };
2188
+ const scopeWidth = Math.max(
2189
+ visibleLength(headers.scope),
2190
+ ...summaryRows.map((r) => visibleLength(r.scope))
2191
+ );
2192
+ const doneWidth = Math.max(
2193
+ visibleLength(headers.done),
2194
+ ...summaryRows.map((r) => visibleLength(r.done))
2195
+ );
2196
+ const statusWidth = Math.max(
2197
+ visibleLength(headers.status),
2198
+ ...summaryRows.map((r) => visibleLength(r.status))
2199
+ );
2200
+ const pad = (text, width) => ` ${padRightStyled(text, width)} `;
2201
+ const centerPad = (text, width) => {
2202
+ const totalPad = width - visibleLength(text);
2203
+ const left = Math.floor(totalPad / 2);
2204
+ const right = totalPad - left;
2205
+ return ` ${" ".repeat(left)}${text}${" ".repeat(right)} `;
2206
+ };
2207
+ const border = (text) => style(text, ansi.dim);
2208
+ const hLine = (left, mid, right) => border(
2209
+ `${left}${"\u2500".repeat(scopeWidth + 2)}${mid}${"\u2500".repeat(doneWidth + 2)}${mid}${"\u2500".repeat(statusWidth + 2)}${right}`
2210
+ );
2211
+ console.log();
2212
+ console.log(hLine("\u250C", "\u252C", "\u2510"));
2213
+ console.log(
2214
+ `${border("\u2502")}${centerPad(headers.scope, scopeWidth)}${border("\u2502")}${centerPad(headers.done, doneWidth)}${border("\u2502")}${centerPad(headers.status, statusWidth)}${border("\u2502")}`
2215
+ );
2216
+ console.log(hLine("\u251C", "\u253C", "\u2524"));
2217
+ for (let i = 0; i < summaryRows.length; i++) {
2218
+ const row = summaryRows[i];
2219
+ console.log(
2220
+ `${border("\u2502")}${pad(row.scope, scopeWidth)}${border("\u2502")}${pad(row.done, doneWidth)}${border("\u2502")}${pad(row.status, statusWidth)}${border("\u2502")}`
2221
+ );
2222
+ if (i < summaryRows.length - 1) {
2223
+ console.log(hLine("\u251C", "\u253C", "\u2524"));
2224
+ }
2225
+ }
2226
+ console.log(hLine("\u2514", "\u2534", "\u2518"));
2227
+ }
2228
+
1924
2229
  // cli/server.ts
1925
2230
  import fs2 from "fs";
1926
2231
  import http from "http";
@@ -2106,21 +2411,26 @@ program.command("open").description("Open an existing .vertical file in the brow
2106
2411
  const filePath = resolveFilePath(file);
2107
2412
  await startServer(filePath);
2108
2413
  });
2109
- program.command("show").description("Print the board to the terminal").argument("<file>", "Path to the .vertical file").option("--json", "Output as JSON").option("--box <slice-id>", "Show only a specific box").action((file, options) => {
2110
- const filePath = resolveFilePath(file, options.json);
2111
- const state = loadState(filePath);
2112
- if (options.box) {
2113
- const slice = state.slices.find((s) => s.id === options.box);
2114
- if (!slice) {
2115
- fail(`Box not found: ${options.box}`, options.json);
2414
+ program.command("show").description("Print the board to the terminal").argument("<file>", "Path to the .vertical file").option("--json", "Output as JSON").option("--box <slice-id>", "Show only a specific box").option("--visual", "Show the board as a visual 3x3 grid with summary").action(
2415
+ (file, options) => {
2416
+ const filePath = resolveFilePath(file, options.json);
2417
+ const state = loadState(filePath);
2418
+ if (options.box) {
2419
+ const slice = state.slices.find((s) => s.id === options.box);
2420
+ if (!slice) {
2421
+ fail(`Box not found: ${options.box}`, options.json);
2422
+ }
2423
+ }
2424
+ if (options.json) {
2425
+ showBoardJson(state);
2426
+ } else if (options.visual) {
2427
+ showBoardGrid(state, options.box);
2428
+ showSummaryTable(state, options.box);
2429
+ } else {
2430
+ showBoard(state, options.box);
2116
2431
  }
2117
2432
  }
2118
- if (options.json) {
2119
- showBoardJson(state);
2120
- } else {
2121
- showBoard(state, options.box);
2122
- }
2123
- });
2433
+ );
2124
2434
  program.command("rename").description("Rename the project").argument("<file>", "Path to the .vertical file").argument("<name>", "New project name").option("--json", "Output as JSON").action((file, name, options) => {
2125
2435
  const filePath = resolveFilePath(file, options.json);
2126
2436
  const state = applyAction(filePath, { type: "RENAME_PROJECT", name });
@@ -2214,6 +2524,39 @@ task.command("move").description("Move a task to another layer").argument("<file
2214
2524
  output(state, Boolean(options.json), `Task moved (id: ${taskId})`);
2215
2525
  }
2216
2526
  );
2527
+ task.command("notes").description("Get, set, or clear notes for a task").argument("<file>", "Path to the .vertical file").argument("<task-id>", "Task ID").option("--set <html>", "Set the notes HTML content").option("--clear", "Clear the notes").option("--json", "Output as JSON").action(
2528
+ (file, taskId, options) => {
2529
+ const filePath = resolveFilePath(file, options.json);
2530
+ if (options.set !== void 0) {
2531
+ const state = applyAction(filePath, {
2532
+ type: "SET_TASK_NOTES",
2533
+ taskId,
2534
+ notesHtml: options.set
2535
+ });
2536
+ output(state, Boolean(options.json), `Notes set (id: ${taskId})`);
2537
+ return;
2538
+ }
2539
+ if (options.clear) {
2540
+ const state = applyAction(filePath, {
2541
+ type: "SET_TASK_NOTES",
2542
+ taskId,
2543
+ notesHtml: null
2544
+ });
2545
+ output(state, Boolean(options.json), `Notes cleared (id: ${taskId})`);
2546
+ return;
2547
+ }
2548
+ const current = loadState(filePath);
2549
+ const foundTask = current.tasks.find((t) => t.id === taskId);
2550
+ if (!foundTask) {
2551
+ fail(`Task not found: ${taskId}`, options.json);
2552
+ }
2553
+ if (options.json) {
2554
+ console.log(JSON.stringify(current, null, 2));
2555
+ } else {
2556
+ console.log(foundTask.notesHtml ?? "(no notes)");
2557
+ }
2558
+ }
2559
+ );
2217
2560
  var box = program.command("box").description("Manage boxes (slices)");
2218
2561
  box.command("rename").description("Rename a box").argument("<file>", "Path to the .vertical file").argument("<slice-id>", "Slice ID").argument("<name>", "New box name").option("--json", "Output as JSON").action(
2219
2562
  (file, sliceId, name, options) => {