@higherdev/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,17 +12,14 @@ Create `~/.config/hd/config.json` with an existing workspace API key:
12
12
 
13
13
  ```json
14
14
  {
15
- "current": "workspace",
16
- "workspaces": {
17
- "workspace": {
18
- "url": "https://hdx-higher-ops.vercel.app",
19
- "api_key": "hdx_..."
20
- }
21
- }
15
+ "url": "https://hdx-higher-ops.vercel.app",
16
+ "api_key": "hdx_...",
17
+ "current": "workspace"
22
18
  }
23
19
  ```
24
20
 
25
- The previous single-workspace config shape is migrated automatically when it is read.
21
+ One workspace key reaches every workspace. Previous single-workspace and
22
+ workspace-map config shapes are migrated automatically when they are read.
26
23
 
27
24
  ## Commands
28
25
 
@@ -33,7 +30,11 @@ The previous single-workspace config shape is migrated automatically when it is
33
30
  | `hd status` | Show workspace, ticket, run, and decision status |
34
31
  | `hd ticket list` | List tickets |
35
32
  | `hd ticket show KEY` | Show one ticket |
36
- | `hd ticket new --title TITLE [options]` | Create a ticket |
33
+ | `hd ticket new --title TITLE [--acceptance TEXT] [options]` | Create a ticket |
34
+ | `hd ticket queue KEY` | Queue a complete ticket now |
35
+ | `hd epic new PATH [--title TITLE]` | Create an epic from a Markdown spec |
36
+ | `hd epic list` | List epics and ticket progress |
37
+ | `hd workspace ls` | List every workspace available to the configured key |
37
38
  | `hd workspace new --name NAME --repo OWNER/NAME [options]` | Create a paused workspace |
38
39
  | `hd agents` | List agents |
39
40
  | `hd agents set ROLE --provider P --model M [--effort E]` | Update an agent |
@@ -46,6 +47,7 @@ The previous single-workspace config shape is migrated automatically when it is
46
47
  | `hd init [options]` | Configure this host and runner service |
47
48
  | `hd upgrade [options]` | Refresh this host configuration |
48
49
 
49
- Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/decide`, `/agents`,
50
- `/settings`, `/workspace`, `/feed`, `/orchestrator`, `/refresh`, `/help`, or
51
- `/exit`. The display refreshes from the HDX API every five seconds.
50
+ Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/queue`, `/epic new`,
51
+ `/epics`, `/decide`, `/agents`, `/settings`, `/workspace`, `/feed`,
52
+ `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
53
+ the HDX API every five seconds.
package/dist/api.js CHANGED
@@ -1,5 +1,20 @@
1
1
  import { loadConfig } from "./config.js";
2
- async function request(config, method, path, body) {
2
+ export const MAX_API_ERROR_BODY = 500;
3
+ export function apiErrorMessage(status, parsed, fallback) {
4
+ const raw = parsed && typeof parsed === "object" && "error" in parsed
5
+ && typeof parsed.error === "string"
6
+ ? parsed.error : fallback;
7
+ const duplicate = raw.match(/agents_one_enabled_(orchestrator|reviewer)_idx/i)?.[1]?.toLowerCase();
8
+ if (duplicate) {
9
+ return `hd: ${status} Only one enabled ${duplicate} is allowed per workspace. Disable the current ${duplicate} before enabling another.`;
10
+ }
11
+ const trimmed = raw.trim();
12
+ const body = trimmed.length > MAX_API_ERROR_BODY
13
+ ? `${trimmed.slice(0, MAX_API_ERROR_BODY)}... [truncated ${trimmed.length - MAX_API_ERROR_BODY} chars]`
14
+ : trimmed;
15
+ return `hd: ${status} ${body}`;
16
+ }
17
+ async function request(config, method, path, body, verbatimError = false) {
3
18
  const response = await fetch(`${config.url}${path}`, {
4
19
  method,
5
20
  headers: {
@@ -14,11 +29,14 @@ async function request(config, method, path, body) {
14
29
  parsed = text ? JSON.parse(text) : null;
15
30
  }
16
31
  catch {
17
- throw new Error(`hd: ${response.status} ${text || response.statusText}`);
32
+ throw new Error(apiErrorMessage(response.status, null, text || response.statusText));
18
33
  }
19
34
  if (!response.ok) {
20
- const err = parsed;
21
- throw new Error(`hd: ${response.status} ${err.error ?? text}`);
35
+ if (verbatimError && parsed && typeof parsed === "object" && "error" in parsed
36
+ && typeof parsed.error === "string") {
37
+ throw new Error(parsed.error);
38
+ }
39
+ throw new Error(apiErrorMessage(response.status, parsed, text || response.statusText));
22
40
  }
23
41
  return parsed;
24
42
  }
@@ -34,6 +52,9 @@ export async function showTicket(key, config = loadConfig()) {
34
52
  export async function createTicket(fields, config = loadConfig()) {
35
53
  return request(config, "POST", `/api/w/${config.slug}/tickets`, fields);
36
54
  }
55
+ export async function queueTicket(key, config = loadConfig()) {
56
+ return request(config, "POST", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/queue`, undefined, true);
57
+ }
37
58
  export async function listTicketEvents(key, afterAt, afterId, config = loadConfig()) {
38
59
  const query = new URLSearchParams();
39
60
  if (afterAt)
@@ -57,6 +78,9 @@ export async function createWorkspace(fields, config = loadConfig()) {
57
78
  const result = await request(config, "POST", "/api/workspaces", fields);
58
79
  return { ...result, url: config.url };
59
80
  }
81
+ export async function listWorkspaces(config = loadConfig()) {
82
+ return request(config, "GET", "/api/workspaces");
83
+ }
60
84
  export async function listAgents(config = loadConfig()) {
61
85
  return request(config, "GET", `/api/w/${config.slug}/agents`);
62
86
  }
@@ -66,6 +90,9 @@ export async function updateAgent(id, fields, config = loadConfig()) {
66
90
  export async function listEpics(config = loadConfig()) {
67
91
  return request(config, "GET", `/api/w/${config.slug}/epics`);
68
92
  }
93
+ export async function createEpic(fields, config = loadConfig()) {
94
+ return request(config, "POST", `/api/w/${config.slug}/epics`, fields);
95
+ }
69
96
  export async function listMessages(options = {}, config = loadConfig()) {
70
97
  const query = new URLSearchParams();
71
98
  if (options.ticketId)
package/dist/config.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { homedir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  export function configPath() {
5
5
  return process.env.HD_CONFIG ?? join(homedir(), ".config/hd/config.json");
6
6
  }
7
- function normalizeWorkspace(value) {
7
+ function normalizeConnection(value) {
8
8
  if (!value || typeof value !== "object" || Array.isArray(value))
9
9
  return null;
10
10
  const row = value;
@@ -14,7 +14,7 @@ function normalizeWorkspace(value) {
14
14
  return { url: row.url.replace(/\/$/, ""), api_key: row.api_key };
15
15
  }
16
16
  function missing(path) {
17
- return new Error(`missing ${path}\nWrite { "current": "workspace", "workspaces": { "workspace": { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_..." } } }`);
17
+ return new Error(`missing ${path}\nWrite { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_...", "current": "workspace" }`);
18
18
  }
19
19
  export function loadStoredConfig(path = configPath()) {
20
20
  let raw;
@@ -25,56 +25,51 @@ export function loadStoredConfig(path = configPath()) {
25
25
  throw missing(path);
26
26
  }
27
27
  const parsed = JSON.parse(raw);
28
- // 0.4 and earlier stored one workspace at the top level. Rewrite it once so
29
- // every later read sees the multi-workspace format.
30
- const legacy = normalizeWorkspace(parsed);
31
- if (legacy && typeof parsed.slug === "string" && parsed.slug) {
32
- const migrated = { current: parsed.slug, workspaces: { [parsed.slug]: legacy } };
28
+ const connection = normalizeConnection(parsed);
29
+ const current = typeof parsed.current === "string" ? parsed.current : "";
30
+ if (connection && current)
31
+ return { ...connection, current };
32
+ // 0.4 stored one workspace at the top level with `slug` rather than
33
+ // `current`. One key can now reach every workspace, so only the selected
34
+ // slug needs to survive.
35
+ if (connection && typeof parsed.slug === "string" && parsed.slug) {
36
+ const migrated = { ...connection, current: parsed.slug };
33
37
  writeStoredConfig(migrated, path);
34
38
  return migrated;
35
39
  }
36
- const current = typeof parsed.current === "string" ? parsed.current : "";
40
+ // 0.5 stored one connection per workspace. Keep the current workspace's
41
+ // credentials as the shared operator connection and discard the duplicates.
37
42
  const source = parsed.workspaces;
38
43
  if (!current || !source || typeof source !== "object" || Array.isArray(source)) {
39
- throw new Error(`${path} needs current and workspaces`);
40
- }
41
- const workspaces = {};
42
- for (const [slug, value] of Object.entries(source)) {
43
- const workspace = normalizeWorkspace(value);
44
- if (workspace)
45
- workspaces[slug] = workspace;
44
+ throw new Error(`${path} needs url, api_key, and current`);
46
45
  }
47
- if (!workspaces[current])
46
+ const selected = normalizeConnection(source[current]);
47
+ if (!selected)
48
48
  throw new Error(`${path} current workspace ${current} is not configured`);
49
- return { current, workspaces };
49
+ const migrated = { ...selected, current };
50
+ writeStoredConfig(migrated, path);
51
+ return migrated;
50
52
  }
51
53
  export function loadConfig(slug) {
52
54
  const stored = loadStoredConfig();
53
55
  const selected = slug ?? stored.current;
54
- const workspace = stored.workspaces[selected];
55
- if (!workspace)
56
- throw new Error(`No configured workspace ${selected}.`);
57
- return { ...workspace, slug: selected };
56
+ return { url: stored.url, api_key: stored.api_key, slug: selected };
58
57
  }
59
58
  export function writeStoredConfig(config, path = configPath()) {
60
59
  mkdirSync(dirname(path), { recursive: true });
61
60
  writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
62
61
  chmodSync(path, 0o600);
63
62
  }
64
- /** Store credentials for a workspace and make it current. */
63
+ /** Store one operator connection and make its selected workspace current. */
65
64
  export function writeHdConfig(config, path = configPath()) {
66
- const stored = existsSync(path)
67
- ? loadStoredConfig(path)
68
- : { current: config.slug, workspaces: {} };
69
- stored.workspaces[config.slug] = { url: config.url.replace(/\/$/, ""), api_key: config.api_key };
70
- stored.current = config.slug;
71
- writeStoredConfig(stored, path);
65
+ writeStoredConfig({
66
+ url: config.url.replace(/\/$/, ""),
67
+ api_key: config.api_key,
68
+ current: config.slug,
69
+ }, path);
72
70
  }
73
71
  export function switchWorkspace(slug, path = configPath()) {
74
72
  const stored = loadStoredConfig(path);
75
- const workspace = stored.workspaces[slug];
76
- if (!workspace)
77
- throw new Error(`No configured workspace ${slug}.`);
78
73
  writeStoredConfig({ ...stored, current: slug }, path);
79
- return { ...workspace, slug };
74
+ return { url: stored.url, api_key: stored.api_key, slug };
80
75
  }
package/dist/epics.js ADDED
@@ -0,0 +1,25 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export function headingTitle(spec) {
3
+ const match = spec.match(/^#[ \t]+(.+?)\s*$/m);
4
+ const title = match?.[1]?.replace(/[ \t]+#+[ \t]*$/, "").trim();
5
+ return title || null;
6
+ }
7
+ export async function readEpicSpec(path, title) {
8
+ const spec_md = await readFile(path, "utf8");
9
+ const resolved = title?.trim() || headingTitle(spec_md);
10
+ if (!resolved)
11
+ throw new Error(`No # heading in ${path}. Pass --title TITLE.`);
12
+ return { title: resolved, spec_md };
13
+ }
14
+ export function epicProgressRows(epics, tickets) {
15
+ return epics.map((epic) => {
16
+ const linked = tickets.filter((ticket) => ticket.epic_id === epic.id);
17
+ return {
18
+ id: epic.id,
19
+ title: epic.title,
20
+ status: epic.status,
21
+ merged: linked.filter((ticket) => ticket.status === "merged").length,
22
+ total: linked.length,
23
+ };
24
+ });
25
+ }
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
- import { answerDecision, createTicket, createWorkspace, getStatus, listAgents, listTicketEvents, listTickets, postMessage, setPaused, showTicket, updateAgent, } from "./api.js";
4
+ import { answerDecision, createEpic, createTicket, createWorkspace, getStatus, listAgents, listEpics, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, setPaused, showTicket, updateAgent, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
- import { writeHdConfig } from "./config.js";
6
+ import { loadConfig, writeHdConfig } from "./config.js";
7
+ import { epicProgressRows, readEpicSpec } from "./epics.js";
7
8
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
8
9
  function fail(message) {
9
10
  console.error(message);
@@ -96,7 +97,7 @@ async function cmdTicket(argv) {
96
97
  if (action === "new") {
97
98
  const { opts } = flags(rest);
98
99
  if (!opts.title)
99
- fail("usage: hd ticket new --title TITLE [--body TEXT] [--area TAG] [--provider NAME] [--epic ID]");
100
+ fail("usage: hd ticket new --title TITLE [--body TEXT] [--acceptance TEXT] [--area TAG] [--provider NAME] [--epic ID]");
100
101
  const { ticket } = await createTicket({
101
102
  title: opts.title,
102
103
  body_md: opts.body,
@@ -108,7 +109,41 @@ async function cmdTicket(argv) {
108
109
  console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)} ${ticket.title}`);
109
110
  return;
110
111
  }
111
- fail("usage: hd ticket list | show KEY | new --title TITLE");
112
+ if (action === "queue") {
113
+ const key = rest[0];
114
+ if (!key)
115
+ fail("usage: hd ticket queue KEY");
116
+ const { ticket } = await queueTicket(key.toUpperCase());
117
+ console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)}`);
118
+ return;
119
+ }
120
+ fail("usage: hd ticket list | show KEY | new --title TITLE | queue KEY");
121
+ }
122
+ async function cmdEpic(argv) {
123
+ const [action, ...rest] = argv;
124
+ if (action === "list") {
125
+ const [{ epics }, { tickets }] = await Promise.all([listEpics(), listTickets()]);
126
+ const rows = epicProgressRows(epics, tickets);
127
+ if (!rows.length) {
128
+ console.log(c.dim("No epics."));
129
+ return;
130
+ }
131
+ console.log(table(["KEY", "STATUS", "PROGRESS", "TITLE"], rows.map((epic) => [
132
+ epic.id, statusChip(epic.status), `${epic.merged}/${epic.total}`, epic.title,
133
+ ])));
134
+ return;
135
+ }
136
+ if (action === "new") {
137
+ const { rest: paths, opts } = flags(rest);
138
+ const path = paths.join(" ");
139
+ if (!path)
140
+ fail("usage: hd epic new PATH [--title TITLE]");
141
+ const input = await readEpicSpec(path, opts.title);
142
+ const { epic } = await createEpic(input);
143
+ console.log(`${c.bold(epic.id)} ${statusChip(epic.status)} ${epic.title}`);
144
+ return;
145
+ }
146
+ fail("usage: hd epic new PATH [--title TITLE] | list");
112
147
  }
113
148
  async function cmdLogs(argv) {
114
149
  const { rest, bools } = flags(argv);
@@ -176,8 +211,20 @@ async function cmdDecide(argv) {
176
211
  }
177
212
  async function cmdWorkspace(argv) {
178
213
  const [action, ...rest] = argv;
214
+ if (action === "ls") {
215
+ const current = loadConfig().slug;
216
+ const workspaces = await listWorkspaces();
217
+ console.log(table(["", "SLUG", "NAME", "REPO", "STATE"], workspaces.map((workspace) => [
218
+ workspace.slug === current ? "*" : "",
219
+ workspace.slug,
220
+ workspace.name,
221
+ workspace.repo,
222
+ workspace.on ? c.green("on") : c.dim("off"),
223
+ ])));
224
+ return;
225
+ }
179
226
  if (action !== "new")
180
- fail("usage: hd workspace new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
227
+ fail("usage: hd workspace ls | new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
181
228
  const { opts } = flags(rest);
182
229
  if (!opts.name || !opts.repo) {
183
230
  fail("usage: hd workspace new --name NAME --repo OWNER/NAME [--slug S] [--branch main] [--host box]");
@@ -252,6 +299,10 @@ export async function main(argv = process.argv.slice(2)) {
252
299
  await cmdTicket(rest);
253
300
  return;
254
301
  }
302
+ if (cmd === "epic") {
303
+ await cmdEpic(rest);
304
+ return;
305
+ }
255
306
  if (cmd === "logs") {
256
307
  await cmdLogs(rest);
257
308
  return;
package/dist/out.js CHANGED
@@ -64,8 +64,9 @@ export function usage() {
64
64
  return [
65
65
  c.bold("Usage"),
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
- ` ${c.blue("hd ticket list | show | new")} ticket operations`,
68
- ` ${c.blue("hd workspace new")} create a paused workspace`,
67
+ ` ${c.blue("hd ticket list | show | new | queue")} ticket operations`,
68
+ ` ${c.blue("hd epic new PATH | list")} epic operations`,
69
+ ` ${c.blue("hd workspace ls | new")} list or create workspaces`,
69
70
  ` ${c.blue("hd agents [set]")} inspect or update agents`,
70
71
  ` ${c.blue("hd logs KEY [-f]")} run events`,
71
72
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
package/dist/tui/App.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
4
+ import { epicProgressRows } from "../epics.js";
4
5
  import { Banner } from "./Banner.js";
5
6
  import { Bubble } from "./Bubble.js";
6
7
  import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js";
@@ -14,7 +15,7 @@ import { alertOnce } from "./alert.js";
14
15
  import { bubbleRows } from "./height.js";
15
16
  import { planLayout, splitPanels } from "./layout.js";
16
17
  import { parseLine } from "./parse.js";
17
- import { configuredSlugs, decisionOptions, loadLiveEvents, loadTicketDetail, pollSnapshot, postOrchestrator, resolveDecision, switchWorkspace, updateAgent, updateProviderCap, } from "./data.js";
18
+ import { configuredSlugs, createEpicFromFile, decisionOptions, loadLiveEvents, loadTicketDetail, pollSnapshot, postOrchestrator, queueTicket, resolveDecision, switchWorkspace, updateAgent, updateProviderCap, } from "./data.js";
18
19
  import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
19
20
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
20
21
  import { UI } from "./theme.js";
@@ -57,6 +58,23 @@ export function App({ initial }) {
57
58
  if (messages.length > 0 || view !== "home")
58
59
  setStarted(true);
59
60
  }, [messages.length, view]);
61
+ useEffect(() => {
62
+ if (!ready)
63
+ return;
64
+ const live = planLayout({
65
+ rows,
66
+ columns,
67
+ width,
68
+ splash: false,
69
+ ready: true,
70
+ decision: decisionRows(board.decisions),
71
+ inFlight: 0,
72
+ notice: false,
73
+ home: true,
74
+ });
75
+ if (live.cockpit > 0)
76
+ setStarted(true);
77
+ }, [ready, rows, columns, width, board.decisions]);
60
78
  const applySnapshot = useCallback((snapshot) => {
61
79
  const token = loads.current.start(snapshot.workspace.id);
62
80
  if (!loads.current.isCurrent(token))
@@ -144,13 +162,9 @@ export function App({ initial }) {
144
162
  }
145
163
  }, [settings, config, refresh]);
146
164
  const changeWorkspace = useCallback(async (slug) => {
147
- if (!configuredSlugs().includes(slug)) {
148
- setNotice(`No configured workspace ${slug}. You can reach: ${configuredSlugs().join(", ")}.`);
149
- return;
150
- }
151
165
  setBusy(true);
152
166
  try {
153
- const snapshot = await switchWorkspace(slug);
167
+ const snapshot = await switchWorkspace(slug, config);
154
168
  loads.current.switchTo(snapshot.workspace.id);
155
169
  setConfig(snapshot.config);
156
170
  setWorkspace(snapshot.workspace);
@@ -265,10 +279,20 @@ export function App({ initial }) {
265
279
  }
266
280
  return;
267
281
  case "workspace":
268
- if (!action.slug)
269
- say("system", `Workspaces: ${configuredSlugs().join(", ")}.`);
270
- else
282
+ if (action.slug)
271
283
  await changeWorkspace(action.slug);
284
+ else {
285
+ setBusy(true);
286
+ try {
287
+ say("system", `Workspaces: ${(await configuredSlugs(config)).join(", ")}.`);
288
+ }
289
+ catch (error) {
290
+ setNotice(error instanceof Error ? error.message : String(error));
291
+ }
292
+ finally {
293
+ setBusy(false);
294
+ }
295
+ }
272
296
  return;
273
297
  case "ticket":
274
298
  setTicketKey(action.key);
@@ -288,6 +312,41 @@ export function App({ initial }) {
288
312
  setBusy(false);
289
313
  }
290
314
  return;
315
+ case "epic-new":
316
+ setBusy(true);
317
+ try {
318
+ const { epic } = await createEpicFromFile(config, action.path);
319
+ say("system", `Created epic ${epic.id}: ${epic.title}`);
320
+ await refresh();
321
+ }
322
+ catch (error) {
323
+ setNotice(error instanceof Error ? error.message : String(error));
324
+ }
325
+ finally {
326
+ setBusy(false);
327
+ }
328
+ return;
329
+ case "epics": {
330
+ const rows = epicProgressRows(board.epics, board.tickets);
331
+ say("system", rows.length
332
+ ? rows.map((epic) => `${epic.id} ${epic.status} ${epic.merged}/${epic.total} ${epic.title}`).join("\n")
333
+ : "No epics.");
334
+ return;
335
+ }
336
+ case "queue":
337
+ setBusy(true);
338
+ try {
339
+ const { ticket: queued } = await queueTicket(config, action.key);
340
+ say("system", `${queued.key} queued.`);
341
+ await refresh();
342
+ }
343
+ catch (error) {
344
+ setNotice(error instanceof Error ? error.message : String(error));
345
+ }
346
+ finally {
347
+ setBusy(false);
348
+ }
349
+ return;
291
350
  case "decide": {
292
351
  const decision = board.decisions[0];
293
352
  if (!decision) {
@@ -130,7 +130,7 @@ export function AgentsColumn({ board, width, rows, }) {
130
130
  const name = Math.max(8, Math.min(22, width - 14));
131
131
  return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Box, { width: name, flexShrink: 0, children: _jsx(Text, { color: UI.text, wrap: "truncate", children: truncate(agent.display_name, name - 1) }) }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: run
132
132
  ? `${ticket ? `${ticket.key} ` : ""}${elapsed(run.started_at ?? run.created_at)}`
133
- : blocked || (agent.enabled ? "idle" : "off") })] }, agent.id));
133
+ : blocked || (agent.enabled ? "idle" : "offline") })] }, agent.id));
134
134
  }),
135
135
  _jsx(More, { count: ordered.length - shown.length }, "more"),
136
136
  ] }));
package/dist/tui/Help.js CHANGED
@@ -8,6 +8,9 @@ export const COMMANDS = [
8
8
  { name: "/board", help: "the kanban board" },
9
9
  { name: "/inbox", help: "decisions and messages waiting on you" },
10
10
  { name: "/ticket", args: "HD-12", help: "open one ticket" },
11
+ { name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
12
+ { name: "/epic", args: "new PATH", help: "create an epic from a Markdown spec" },
13
+ { name: "/epics", help: "list epics and ticket progress" },
11
14
  { name: "/decide", args: "2 | text", help: "answer the decision on screen" },
12
15
  { name: "/agents", help: "every agent in full, and the live run stream" },
13
16
  { name: "/settings", help: "change provider caps and agent settings" },
package/dist/tui/data.js CHANGED
@@ -1,5 +1,6 @@
1
- import { answerDecision, getStatus, listAgents, listEpics, listFeed, listMessages, listTicketEvents, listTickets, postMessage as sendMessage, showTicket, updateAgent as patchAgent, updateCaps, } from "../api.js";
2
- import { loadConfig, loadStoredConfig, switchWorkspace as selectWorkspace, } from "../config.js";
1
+ import { answerDecision, createEpic as postEpic, getStatus, listAgents, listEpics, listFeed, listMessages, listTicketEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, showTicket, updateAgent as patchAgent, updateCaps, } from "../api.js";
2
+ import { loadConfig, switchWorkspace as selectWorkspace, } from "../config.js";
3
+ import { readEpicSpec } from "../epics.js";
3
4
  export const POLL_MS = 5_000;
4
5
  export const providers = ["claude", "codex", "gemini", "grok"];
5
6
  export const efforts = ["low", "medium", "high"];
@@ -17,8 +18,8 @@ export const BOARD_COLUMNS = [
17
18
  "merged",
18
19
  "cancelled",
19
20
  ];
20
- export function configuredSlugs() {
21
- return Object.keys(loadStoredConfig().workspaces).sort();
21
+ export async function configuredSlugs(config = loadConfig()) {
22
+ return (await listWorkspaces(config)).map((workspace) => workspace.slug).sort();
22
23
  }
23
24
  export async function loadSnapshot(config = loadConfig()) {
24
25
  const [status, ticketData, agentData, epicData, feedData] = await Promise.all([
@@ -83,8 +84,14 @@ export function pollSnapshot(config, onSnapshot, onState, onError) {
83
84
  void refresh();
84
85
  return { refresh, close: () => { closed = true; clearInterval(timer); } };
85
86
  }
86
- export async function switchWorkspace(slug) {
87
- return loadSnapshot(selectWorkspace(slug));
87
+ export async function switchWorkspace(slug, config = loadConfig()) {
88
+ const slugs = await configuredSlugs(config);
89
+ if (!slugs.includes(slug)) {
90
+ throw new Error(`No workspace ${slug}. You can reach: ${slugs.join(", ")}.`);
91
+ }
92
+ const snapshot = await loadSnapshot({ ...config, slug });
93
+ selectWorkspace(slug);
94
+ return snapshot;
88
95
  }
89
96
  export async function postOrchestrator(body, config) {
90
97
  await sendMessage({ body_md: body, to_role: "orchestrator", delivery: "queue" }, config);
@@ -92,6 +99,12 @@ export async function postOrchestrator(body, config) {
92
99
  export async function loadTicketDetail(config, key) {
93
100
  return showTicket(key, config);
94
101
  }
102
+ export async function createEpicFromFile(config, path) {
103
+ return postEpic(await readEpicSpec(path), config);
104
+ }
105
+ export async function queueTicket(config, key) {
106
+ return queueTicketNow(key, config);
107
+ }
95
108
  export async function waitForReply(config, since, timeoutMs) {
96
109
  const deadline = Date.now() + timeoutMs;
97
110
  while (Date.now() <= deadline) {
package/dist/tui/parse.js CHANGED
@@ -29,6 +29,16 @@ export function parseLine(raw) {
29
29
  return argument
30
30
  ? { kind: "ticket", key: argument.toUpperCase() }
31
31
  : { kind: "unknown", command: "ticket needs a key" };
32
+ case "epic":
33
+ return rest[0]?.toLowerCase() === "new" && rest.length > 1
34
+ ? { kind: "epic-new", path: rest.slice(1).join(" ") }
35
+ : { kind: "unknown", command: "epic needs new PATH" };
36
+ case "epics":
37
+ return { kind: "epics" };
38
+ case "queue":
39
+ return argument
40
+ ? { kind: "queue", key: argument.toUpperCase() }
41
+ : { kind: "unknown", command: "queue needs a key" };
32
42
  case "decide": {
33
43
  const dismiss = /(^|\s)--(skip|dismiss)(\s|$)/.test(argument);
34
44
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"