itsvertical 0.0.2 → 0.0.3

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
@@ -1921,6 +1921,301 @@ function fail(message, json) {
1921
1921
  process.exit(1);
1922
1922
  }
1923
1923
 
1924
+ // cli/board.ts
1925
+ var MIN_COL_WIDTH = 25;
1926
+ var MAX_COL_WIDTH = 40;
1927
+ var PADDING = 2;
1928
+ var ansi = {
1929
+ reset: "\x1B[0m",
1930
+ bold: "\x1B[1m",
1931
+ dim: "\x1B[2m",
1932
+ green: "\x1B[32m",
1933
+ yellow: "\x1B[33m",
1934
+ cyan: "\x1B[36m"
1935
+ };
1936
+ function style(text, ...codes) {
1937
+ return `${codes.join("")}${text}${ansi.reset}`;
1938
+ }
1939
+ var ANSI_PATTERN = new RegExp(`${"\x1B"}\\[[0-9;]*m`, "g");
1940
+ function stripAnsi(text) {
1941
+ return text.replace(ANSI_PATTERN, "");
1942
+ }
1943
+ function visibleLength(text) {
1944
+ return stripAnsi(text).length;
1945
+ }
1946
+ function truncateStyled(text, maxWidth) {
1947
+ if (visibleLength(text) <= maxWidth)
1948
+ return text;
1949
+ let visible = 0;
1950
+ let i = 0;
1951
+ while (i < text.length && visible < maxWidth - 1) {
1952
+ if (text[i] === "\x1B") {
1953
+ const end = text.indexOf("m", i);
1954
+ i = end + 1;
1955
+ continue;
1956
+ }
1957
+ visible++;
1958
+ i++;
1959
+ }
1960
+ return `${text.slice(0, i)}${ansi.reset}\u2026`;
1961
+ }
1962
+ function padRightStyled(text, width) {
1963
+ const padding = Math.max(0, width - visibleLength(text));
1964
+ return text + " ".repeat(padding);
1965
+ }
1966
+ function getSliceLayers(state, slice) {
1967
+ return sortBy_default(
1968
+ state.layers.filter((l) => l.sliceId === slice.id),
1969
+ "sorting"
1970
+ );
1971
+ }
1972
+ function getLayerTasks(state, layer2) {
1973
+ return sortBy_default(
1974
+ state.tasks.filter((t) => t.layerId === layer2.id),
1975
+ "sorting"
1976
+ );
1977
+ }
1978
+ function getSliceTasks(state, slice) {
1979
+ const layers = getSliceLayers(state, slice);
1980
+ return layers.flatMap((l) => getLayerTasks(state, l));
1981
+ }
1982
+ function buildCellLines(state, slice, innerWidth) {
1983
+ const lines = [];
1984
+ const layers = getSliceLayers(state, slice);
1985
+ const allTasks = getSliceTasks(state, slice);
1986
+ const hasMultipleLayers = layers.length > 1;
1987
+ const separator = style("\u2504".repeat(innerWidth), ansi.dim);
1988
+ if (slice.name) {
1989
+ lines.push(style(slice.name, ansi.bold, ansi.cyan));
1990
+ }
1991
+ if (allTasks.length === 0) {
1992
+ lines.push(style("(no tasks)", ansi.dim));
1993
+ return lines;
1994
+ }
1995
+ for (let i = 0; i < layers.length; i++) {
1996
+ const layer2 = layers[i];
1997
+ const tasks = getLayerTasks(state, layer2);
1998
+ if (hasMultipleLayers && i > 0) {
1999
+ lines.push(separator);
2000
+ }
2001
+ if (hasMultipleLayers) {
2002
+ const layerName = layer2.name ?? `Layer ${i + 1}`;
2003
+ if (layer2.status === "done") {
2004
+ lines.push(style(`\u2713 ${layerName} (done)`, ansi.green));
2005
+ } else {
2006
+ lines.push(style(layerName, ansi.bold));
2007
+ }
2008
+ }
2009
+ for (const task2 of tasks) {
2010
+ if (task2.done) {
2011
+ lines.push(` ${style("\u25CF", ansi.green)} ${style(task2.name, ansi.dim)}`);
2012
+ } else {
2013
+ lines.push(` ${style("\u25CB", ansi.yellow)} ${task2.name}`);
2014
+ }
2015
+ }
2016
+ }
2017
+ return lines;
2018
+ }
2019
+ function showBoardGrid(state, boxId) {
2020
+ const sortedSlices = sortBy_default(state.slices, "boxNumber");
2021
+ const filteredSlices = boxId ? sortedSlices.filter((s) => s.id === boxId) : sortedSlices;
2022
+ const colWidth = Math.min(
2023
+ MAX_COL_WIDTH,
2024
+ Math.max(
2025
+ MIN_COL_WIDTH,
2026
+ ...filteredSlices.flatMap((slice) => {
2027
+ const layers = getSliceLayers(state, slice);
2028
+ const tasks = getSliceTasks(state, slice);
2029
+ const widths = [
2030
+ slice.name?.length ?? 0,
2031
+ ...layers.map((l) => (l.name?.length ?? 0) + 10),
2032
+ ...tasks.map((t) => t.name.length + 4)
2033
+ ];
2034
+ return widths;
2035
+ }).map((w) => w + PADDING * 2)
2036
+ )
2037
+ );
2038
+ const innerWidth = colWidth - PADDING * 2;
2039
+ const numCols = boxId ? 1 : 3;
2040
+ const cellContents = /* @__PURE__ */ new Map();
2041
+ for (const slice of filteredSlices) {
2042
+ cellContents.set(slice.id, buildCellLines(state, slice, innerWidth));
2043
+ }
2044
+ const rows = [];
2045
+ if (boxId) {
2046
+ rows.push(filteredSlices);
2047
+ } else {
2048
+ rows.push(
2049
+ filteredSlices.filter((s) => s.boxNumber >= 1 && s.boxNumber <= 3)
2050
+ );
2051
+ rows.push(
2052
+ filteredSlices.filter((s) => s.boxNumber >= 4 && s.boxNumber <= 6)
2053
+ );
2054
+ rows.push(
2055
+ filteredSlices.filter((s) => s.boxNumber >= 7 && s.boxNumber <= 9)
2056
+ );
2057
+ }
2058
+ console.log(style(state.project.name, ansi.bold));
2059
+ console.log();
2060
+ const border = (text) => style(text, ansi.dim);
2061
+ const makeBorder = (left, mid2, right) => {
2062
+ const parts = [];
2063
+ for (let c = 0; c < numCols; c++) {
2064
+ parts.push("\u2500".repeat(colWidth));
2065
+ }
2066
+ return border(left + parts.join(mid2) + right);
2067
+ };
2068
+ const top = makeBorder("\u250C", "\u252C", "\u2510");
2069
+ const mid = makeBorder("\u251C", "\u253C", "\u2524");
2070
+ const bot = makeBorder("\u2514", "\u2534", "\u2518");
2071
+ for (let r = 0; r < rows.length; r++) {
2072
+ const row = rows[r];
2073
+ console.log(r === 0 ? top : mid);
2074
+ const cellLines = row.map((slice) => cellContents.get(slice.id) ?? []);
2075
+ const rowHeight = Math.max(3, ...cellLines.map((l) => l.length + 2));
2076
+ for (let line = 0; line < rowHeight; line++) {
2077
+ let output2 = border("\u2502");
2078
+ for (let c = 0; c < numCols; c++) {
2079
+ const lines = cellLines[c] ?? [];
2080
+ const contentLine = line - 1;
2081
+ let content = "";
2082
+ if (contentLine >= 0 && contentLine < lines.length) {
2083
+ content = truncateStyled(lines[contentLine], innerWidth);
2084
+ }
2085
+ output2 += `${" ".repeat(PADDING) + padRightStyled(content, innerWidth) + " ".repeat(PADDING)}${border("\u2502")}`;
2086
+ }
2087
+ console.log(output2);
2088
+ }
2089
+ }
2090
+ console.log(bot);
2091
+ const totalTasks = state.tasks.length;
2092
+ const doneTasks = state.tasks.filter((t) => t.done).length;
2093
+ const progressColor = doneTasks === totalTasks ? ansi.green : ansi.yellow;
2094
+ console.log();
2095
+ console.log(
2096
+ `${style("Progress:", ansi.bold)} ${style(`${doneTasks}/${totalTasks}`, ansi.bold, progressColor)} tasks done`
2097
+ );
2098
+ }
2099
+ function formatGroupLabel(boxNumbers) {
2100
+ if (boxNumbers.length === 0)
2101
+ return "";
2102
+ if (boxNumbers.length === 1)
2103
+ return `Box ${boxNumbers[0]}`;
2104
+ const ranges = [];
2105
+ let start = boxNumbers[0];
2106
+ let end = boxNumbers[0];
2107
+ for (let i = 1; i < boxNumbers.length; i++) {
2108
+ if (boxNumbers[i] === end + 1) {
2109
+ end = boxNumbers[i];
2110
+ } else {
2111
+ ranges.push(start === end ? `${start}` : `${start}\u2013${end}`);
2112
+ start = boxNumbers[i];
2113
+ end = boxNumbers[i];
2114
+ }
2115
+ }
2116
+ ranges.push(start === end ? `${start}` : `${start}\u2013${end}`);
2117
+ return `Boxes ${ranges.join(", ")}`;
2118
+ }
2119
+ function buildStatusText(state, slice) {
2120
+ const layers = getSliceLayers(state, slice);
2121
+ const tasks = getSliceTasks(state, slice);
2122
+ if (tasks.length === 0)
2123
+ return style("No tasks yet", ansi.dim);
2124
+ const doneTasks = tasks.filter((t) => t.done).length;
2125
+ const totalTasks = tasks.length;
2126
+ if (doneTasks === totalTasks)
2127
+ return style("All done", ansi.green);
2128
+ if (layers.length > 1) {
2129
+ const doneLayerNames = layers.filter((l) => l.status === "done").map((l) => {
2130
+ const idx = layers.indexOf(l);
2131
+ return l.name ?? `Layer ${idx + 1}`;
2132
+ });
2133
+ const openCount = totalTasks - doneTasks;
2134
+ const parts = [];
2135
+ if (doneLayerNames.length > 0) {
2136
+ parts.push(`${doneLayerNames.join(", ")} marked done`);
2137
+ }
2138
+ if (openCount > 0) {
2139
+ parts.push(`${openCount} task${openCount === 1 ? "" : "s"} still open`);
2140
+ }
2141
+ return parts.join(", ");
2142
+ }
2143
+ return `${doneTasks}/${totalTasks} tasks done`;
2144
+ }
2145
+ function showSummaryTable(state, boxId) {
2146
+ const sortedSlices = sortBy_default(state.slices, "boxNumber");
2147
+ const filteredSlices = boxId ? sortedSlices.filter((s) => s.id === boxId) : sortedSlices;
2148
+ const summaryRows = [];
2149
+ const unnamedEmpty = [];
2150
+ for (const slice of filteredSlices) {
2151
+ const tasks = getSliceTasks(state, slice);
2152
+ if (!slice.name && tasks.length === 0) {
2153
+ unnamedEmpty.push(slice.boxNumber);
2154
+ continue;
2155
+ }
2156
+ const doneTasks = tasks.filter((t) => t.done).length;
2157
+ const doneColor = tasks.length > 0 && doneTasks === tasks.length ? ansi.green : ansi.yellow;
2158
+ summaryRows.push({
2159
+ scope: style(slice.name ?? `Box ${slice.boxNumber}`, ansi.bold),
2160
+ done: tasks.length > 0 ? style(`${doneTasks}/${tasks.length}`, doneColor) : style("\u2014", ansi.dim),
2161
+ status: buildStatusText(state, slice)
2162
+ });
2163
+ }
2164
+ if (unnamedEmpty.length > 0) {
2165
+ summaryRows.push({
2166
+ scope: style(formatGroupLabel(unnamedEmpty), ansi.dim),
2167
+ done: style("\u2014", ansi.dim),
2168
+ status: style("Unnamed & empty", ansi.dim)
2169
+ });
2170
+ }
2171
+ if (summaryRows.length === 0)
2172
+ return;
2173
+ const headers = {
2174
+ scope: style("Scope", ansi.bold),
2175
+ done: style("Done", ansi.bold),
2176
+ status: style("Status", ansi.bold)
2177
+ };
2178
+ const scopeWidth = Math.max(
2179
+ visibleLength(headers.scope),
2180
+ ...summaryRows.map((r) => visibleLength(r.scope))
2181
+ );
2182
+ const doneWidth = Math.max(
2183
+ visibleLength(headers.done),
2184
+ ...summaryRows.map((r) => visibleLength(r.done))
2185
+ );
2186
+ const statusWidth = Math.max(
2187
+ visibleLength(headers.status),
2188
+ ...summaryRows.map((r) => visibleLength(r.status))
2189
+ );
2190
+ const pad = (text, width) => ` ${padRightStyled(text, width)} `;
2191
+ const centerPad = (text, width) => {
2192
+ const totalPad = width - visibleLength(text);
2193
+ const left = Math.floor(totalPad / 2);
2194
+ const right = totalPad - left;
2195
+ return ` ${" ".repeat(left)}${text}${" ".repeat(right)} `;
2196
+ };
2197
+ const border = (text) => style(text, ansi.dim);
2198
+ const hLine = (left, mid, right) => border(
2199
+ `${left}${"\u2500".repeat(scopeWidth + 2)}${mid}${"\u2500".repeat(doneWidth + 2)}${mid}${"\u2500".repeat(statusWidth + 2)}${right}`
2200
+ );
2201
+ console.log();
2202
+ console.log(hLine("\u250C", "\u252C", "\u2510"));
2203
+ console.log(
2204
+ `${border("\u2502")}${centerPad(headers.scope, scopeWidth)}${border("\u2502")}${centerPad(headers.done, doneWidth)}${border("\u2502")}${centerPad(headers.status, statusWidth)}${border("\u2502")}`
2205
+ );
2206
+ console.log(hLine("\u251C", "\u253C", "\u2524"));
2207
+ for (let i = 0; i < summaryRows.length; i++) {
2208
+ const row = summaryRows[i];
2209
+ console.log(
2210
+ `${border("\u2502")}${pad(row.scope, scopeWidth)}${border("\u2502")}${pad(row.done, doneWidth)}${border("\u2502")}${pad(row.status, statusWidth)}${border("\u2502")}`
2211
+ );
2212
+ if (i < summaryRows.length - 1) {
2213
+ console.log(hLine("\u251C", "\u253C", "\u2524"));
2214
+ }
2215
+ }
2216
+ console.log(hLine("\u2514", "\u2534", "\u2518"));
2217
+ }
2218
+
1924
2219
  // cli/server.ts
1925
2220
  import fs2 from "fs";
1926
2221
  import http from "http";
@@ -2106,21 +2401,26 @@ program.command("open").description("Open an existing .vertical file in the brow
2106
2401
  const filePath = resolveFilePath(file);
2107
2402
  await startServer(filePath);
2108
2403
  });
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);
2404
+ 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(
2405
+ (file, options) => {
2406
+ const filePath = resolveFilePath(file, options.json);
2407
+ const state = loadState(filePath);
2408
+ if (options.box) {
2409
+ const slice = state.slices.find((s) => s.id === options.box);
2410
+ if (!slice) {
2411
+ fail(`Box not found: ${options.box}`, options.json);
2412
+ }
2413
+ }
2414
+ if (options.json) {
2415
+ showBoardJson(state);
2416
+ } else if (options.visual) {
2417
+ showBoardGrid(state, options.box);
2418
+ showSummaryTable(state, options.box);
2419
+ } else {
2420
+ showBoard(state, options.box);
2116
2421
  }
2117
2422
  }
2118
- if (options.json) {
2119
- showBoardJson(state);
2120
- } else {
2121
- showBoard(state, options.box);
2122
- }
2123
- });
2423
+ );
2124
2424
  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
2425
  const filePath = resolveFilePath(file, options.json);
2126
2426
  const state = applyAction(filePath, { type: "RENAME_PROJECT", name });