@higherdev/cli 0.11.0 → 0.12.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
@@ -14,7 +14,8 @@ Create `~/.config/hd/config.json` with an existing workspace API key:
14
14
  {
15
15
  "url": "https://hdx-higher-ops.vercel.app",
16
16
  "api_key": "hdx_...",
17
- "current": "workspace"
17
+ "current": "workspace",
18
+ "repos": {}
18
19
  }
19
20
  ```
20
21
 
@@ -35,7 +36,9 @@ workspace-map config shapes are migrated automatically when they are read.
35
36
  | `hd ticket cancel KEY` | Cancel a ticket |
36
37
  | `hd epic new PATH [--title TITLE]` | Create an epic from a Markdown spec |
37
38
  | `hd epic list` | List epics and ticket progress |
38
- | `hd plan [--repo DIR]` | Hand the terminal to Codex to author an epic spec |
39
+ | `hd epic approve ID` | Open a draft epic for orchestrator decomposition |
40
+ | `hd epic rm ID` | Remove a draft epic |
41
+ | `hd plan` | Point to the TUI `/architect` conversation |
39
42
  | `hd workspace ls` | List every workspace available to the configured key |
40
43
  | `hd workspace new --name NAME --repo OWNER/NAME [options]` | Preflight GitHub, wire the runner, and create a paused workspace |
41
44
  | `hd workspace set [options]` | Update settings; max turns uses `--max-turns KIND=N[,KIND=N...]` |
@@ -61,7 +64,7 @@ the repository is missing, approve private creation interactively, use `--create
61
64
  `--no-create` to fail. Fully flagged calls remain non-interactive for scripts and Mel.
62
65
 
63
66
  Inside the TUI, use `/board`, `/inbox`, `/ticket`, `/queue`, `/cancel`, `/epic new`,
64
- `/epics`, `/plan`, `/decide`, `/agents add`, `/agents rm`, `/settings`, `/workspace`,
67
+ `/epic approve`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/settings`, `/workspace`,
65
68
  `/workspace new`, `/workspace set`, `/workspace rotate-key`, `/feed`,
66
- `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
69
+ `/on`, `/off`, `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
67
70
  the HDX API every five seconds.
package/dist/api.js CHANGED
@@ -4,7 +4,7 @@ export function apiErrorMessage(status, parsed, fallback) {
4
4
  const raw = parsed && typeof parsed === "object" && "error" in parsed
5
5
  && typeof parsed.error === "string"
6
6
  ? parsed.error : fallback;
7
- const duplicate = raw.match(/agents_one_enabled_(orchestrator|reviewer)_idx/i)?.[1]?.toLowerCase();
7
+ const duplicate = raw.match(/agents_one_enabled_(architect|orchestrator|reviewer)_idx/i)?.[1]?.toLowerCase();
8
8
  if (duplicate) {
9
9
  return `hd: ${status} Only one enabled ${duplicate} is allowed per workspace. Disable the current ${duplicate} before enabling another.`;
10
10
  }
@@ -111,6 +111,12 @@ export async function listEpics(config = loadConfig()) {
111
111
  export async function createEpic(fields, config = loadConfig()) {
112
112
  return request(config, "POST", `/api/w/${config.slug}/epics`, fields);
113
113
  }
114
+ export async function approveEpic(id, config = loadConfig()) {
115
+ return request(config, "PATCH", `/api/w/${config.slug}/epics`, { id });
116
+ }
117
+ export async function deleteEpic(id, config = loadConfig()) {
118
+ return request(config, "DELETE", `/api/w/${config.slug}/epics`, { id });
119
+ }
114
120
  export async function listMessages(options = {}, config = loadConfig()) {
115
121
  const query = new URLSearchParams();
116
122
  if (options.ticketId)
package/dist/config.js CHANGED
@@ -16,6 +16,11 @@ function normalizeConnection(value) {
16
16
  function missing(path) {
17
17
  return new Error(`missing ${path}\nWrite { "url": "https://hdx-higher-ops.vercel.app", "api_key": "hdx_...", "current": "workspace" }`);
18
18
  }
19
+ function normalizeRepos(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value))
21
+ return {};
22
+ return Object.fromEntries(Object.entries(value).filter((entry) => Boolean(entry[0]) && typeof entry[1] === "string" && Boolean(entry[1])));
23
+ }
19
24
  export function loadStoredConfig(path = configPath()) {
20
25
  let raw;
21
26
  try {
@@ -27,13 +32,14 @@ export function loadStoredConfig(path = configPath()) {
27
32
  const parsed = JSON.parse(raw);
28
33
  const connection = normalizeConnection(parsed);
29
34
  const current = typeof parsed.current === "string" ? parsed.current : "";
35
+ const repos = normalizeRepos(parsed.repos);
30
36
  if (connection && current)
31
- return { ...connection, current };
37
+ return { ...connection, current, repos };
32
38
  // 0.4 stored one workspace at the top level with `slug` rather than
33
39
  // `current`. One key can now reach every workspace, so only the selected
34
40
  // slug needs to survive.
35
41
  if (connection && typeof parsed.slug === "string" && parsed.slug) {
36
- const migrated = { ...connection, current: parsed.slug };
42
+ const migrated = { ...connection, current: parsed.slug, repos };
37
43
  writeStoredConfig(migrated, path);
38
44
  return migrated;
39
45
  }
@@ -46,7 +52,7 @@ export function loadStoredConfig(path = configPath()) {
46
52
  const selected = normalizeConnection(source[current]);
47
53
  if (!selected)
48
54
  throw new Error(`${path} current workspace ${current} is not configured`);
49
- const migrated = { ...selected, current };
55
+ const migrated = { ...selected, current, repos };
50
56
  writeStoredConfig(migrated, path);
51
57
  return migrated;
52
58
  }
@@ -62,10 +68,16 @@ export function writeStoredConfig(config, path = configPath()) {
62
68
  }
63
69
  /** Store one operator connection and make its selected workspace current. */
64
70
  export function writeHdConfig(config, path = configPath()) {
71
+ let repos = {};
72
+ try {
73
+ repos = loadStoredConfig(path).repos;
74
+ }
75
+ catch { }
65
76
  writeStoredConfig({
66
77
  url: config.url.replace(/\/$/, ""),
67
78
  api_key: config.api_key,
68
79
  current: config.slug,
80
+ repos,
69
81
  }, path);
70
82
  }
71
83
  export function switchWorkspace(slug, path = configPath()) {
@@ -73,3 +85,7 @@ export function switchWorkspace(slug, path = configPath()) {
73
85
  writeStoredConfig({ ...stored, current: slug }, path);
74
86
  return { url: stored.url, api_key: stored.api_key, slug };
75
87
  }
88
+ export function rememberWorkspaceRepo(slug, directory, path = configPath()) {
89
+ const stored = loadStoredConfig(path);
90
+ writeStoredConfig({ ...stored, repos: { ...stored.repos, [slug]: directory } }, path);
91
+ }
package/dist/index.js CHANGED
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
- import { answerDecision, cancelTicket, createAgent, createEpic, createTicket, deleteAgent, getStatus, listAgents, listEpics, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, setPaused, showTicket, updateAgent, updateCaps, } from "./api.js";
4
+ import { approveEpic, answerDecision, cancelTicket, createAgent, createEpic, createTicket, deleteAgent, deleteEpic, getStatus, listAgents, listEpics, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, setPaused, showTicket, updateAgent, updateCaps, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
6
  import { loadConfig } from "./config.js";
7
7
  import { epicProgressRows, readEpicSpec } from "./epics.js";
8
8
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
9
- import { launchArchitect } from "./plan.js";
10
9
  import { WORKSPACE_USAGE, workspaceNew, workspaceRotateKey, workspaceSet } from "./workspace-commands.js";
11
10
  function fail(message) {
12
11
  console.error(message);
@@ -153,15 +152,26 @@ async function cmdEpic(argv) {
153
152
  console.log(`${c.bold(epic.id)} ${statusChip(epic.status)} ${epic.title}`);
154
153
  return;
155
154
  }
156
- fail("usage: hd epic new PATH [--title TITLE] | list");
155
+ if (action === "approve" || action === "rm") {
156
+ const id = rest[0];
157
+ if (!id || rest.length !== 1)
158
+ fail(`usage: hd epic ${action} ID`);
159
+ if (action === "approve") {
160
+ const { epic } = await approveEpic(id);
161
+ console.log(`${c.bold(epic.id)} ${statusChip(epic.status)} ${epic.title}`);
162
+ }
163
+ else {
164
+ await deleteEpic(id);
165
+ console.log(`removed ${id}`);
166
+ }
167
+ return;
168
+ }
169
+ fail("usage: hd epic new PATH [--title TITLE] | list | approve ID | rm ID");
157
170
  }
158
171
  async function cmdPlan(argv) {
159
- const { rest, opts, bools } = flags(argv);
160
- if (rest.length || bools.size || Object.keys(opts).some((key) => key !== "repo")) {
161
- fail("usage: hd plan [--repo DIR]");
162
- }
163
- const { workspace } = await getStatus();
164
- await launchArchitect({ workspaceRepo: workspace.repo, repo: opts.repo });
172
+ if (argv.length)
173
+ fail("usage: hd plan");
174
+ console.log("Open the HDX TUI and use /architect (or /plan).");
165
175
  }
166
176
  async function cmdLogs(argv) {
167
177
  const { rest, bools } = flags(argv);
package/dist/out.js CHANGED
@@ -65,8 +65,8 @@ export function usage() {
65
65
  c.bold("Usage"),
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
67
  ` ${c.blue("hd ticket list | show | new | queue | cancel")} ticket operations`,
68
- ` ${c.blue("hd epic new PATH | list")} epic operations`,
69
- ` ${c.blue("hd plan [--repo DIR]")} author an epic with Codex`,
68
+ ` ${c.blue("hd epic new PATH | list | approve | rm")} epic operations`,
69
+ ` ${c.blue("hd plan")} use /architect in the TUI`,
70
70
  ` ${c.blue("hd workspace ls | new | set | rotate-key")} workspace operations`,
71
71
  ` ${c.blue("hd agents [add | rm | set]")} manage agents`,
72
72
  ` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
package/dist/prompt.js ADDED
@@ -0,0 +1,13 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ export async function promptOnStdin(question, resumeStdin = false) {
3
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
4
+ try {
5
+ return await prompt.question(question);
6
+ }
7
+ finally {
8
+ prompt.close();
9
+ // Ink restores raw mode and listeners, but it cannot read a paused stream.
10
+ if (resumeStdin)
11
+ process.stdin.resume();
12
+ }
13
+ }
package/dist/tui/App.js CHANGED
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { epicProgressRows } from "../epics.js";
6
- import { launchArchitect } from "../plan.js";
6
+ import { promptOnStdin } from "../prompt.js";
7
7
  import { workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
8
8
  import { Banner } from "./Banner.js";
9
9
  import { Bubble } from "./Bubble.js";
@@ -18,13 +18,14 @@ import { alertOnce } from "./alert.js";
18
18
  import { bubbleRows } from "./height.js";
19
19
  import { planLayout, splitPanels } from "./layout.js";
20
20
  import { parseLine } from "./parse.js";
21
- import { configuredSlugs, cancelTicket, createAgent, createEpicFromFile, decisionOptions, deleteAgent, loadLiveEvents, loadTicketDetail, pollSnapshot, postOrchestrator, queueTicket, resolveDecision, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, } from "./data.js";
21
+ import { configuredSlugs, approveEpic, cancelTicket, createAgent, createEpicFromFile, decisionOptions, deleteAgent, loadLiveEvents, loadTicketDetail, pollSnapshot, postAgentMessage, queueTicket, resolveDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, } from "./data.js";
22
22
  import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
23
23
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
24
24
  import { UI } from "./theme.js";
25
25
  import { WorkspaceLoads } from "./workspace-load.js";
26
26
  let messageSeq = 0;
27
27
  const nextId = () => `m${messageSeq++}`;
28
+ const tuiPrompt = (question) => promptOnStdin(question, true);
28
29
  export function App({ initial }) {
29
30
  const { exit, suspendTerminal } = useApp();
30
31
  const { stdout } = useStdout();
@@ -191,18 +192,18 @@ export function App({ initial }) {
191
192
  setBusy(false);
192
193
  }
193
194
  }, [config, say]);
194
- const askOrchestrator = useCallback(async (text) => {
195
+ const askAgent = useCallback(async (role, text) => {
195
196
  setBusy(true);
196
197
  const id = nextId();
197
- setMessages((prior) => [...prior, { id, speaker: "orchestrator", body: "", pending: true }]);
198
+ setMessages((prior) => [...prior, { id, speaker: role, body: "", pending: true }]);
198
199
  const since = new Date().toISOString();
199
200
  try {
200
- await postOrchestrator(text, config);
201
+ await postAgentMessage(role, text, config);
201
202
  setMessages((prior) => prior.map((message) => message.id === id
202
203
  ? { ...message, steps: ["· queued, it answers on the next tick"] }
203
204
  : message));
204
205
  const { waitForReply } = await import("./data.js");
205
- const reply = await waitForReply(config, since, 180_000);
206
+ const reply = await waitForReply(config, role, since, 180_000);
206
207
  setMessages((prior) => prior.map((message) => message.id === id
207
208
  ? { ...message, body: reply ?? "No reply yet. It will land in /inbox.", pending: false, steps: [] }
208
209
  : message));
@@ -256,18 +257,20 @@ export function App({ initial }) {
256
257
  const action = parseLine(text);
257
258
  if (action.kind === "say") {
258
259
  say("you", text);
259
- if (mode === "orchestrator")
260
- await askOrchestrator(text);
260
+ if (mode !== "browse")
261
+ await askAgent(mode, text);
261
262
  else
262
- setNotice("Use /orchestrator before sending a message.");
263
+ setNotice("Use /architect or /orchestrator before sending a message.");
263
264
  return;
264
265
  }
265
266
  switch (action.kind) {
266
267
  case "mode":
267
- setMode("orchestrator");
268
+ setMode(action.mode);
268
269
  setCursor(null);
269
270
  selectedRef.current = null;
270
- say("system", "Talking to the orchestrator. It moves work already in flight.");
271
+ say("system", action.mode === "architect"
272
+ ? "Talking to the architect. Ask questions, refine the epic, then explicitly request a draft."
273
+ : "Talking to the orchestrator. It moves work already in flight.");
271
274
  return;
272
275
  case "view":
273
276
  setView(action.view);
@@ -305,7 +308,7 @@ export function App({ initial }) {
305
308
  try {
306
309
  if (action.command === "new") {
307
310
  let created;
308
- await suspendTerminal(async () => { created = await workspaceNew(action.args); });
311
+ await suspendTerminal(async () => { created = await workspaceNew(action.args, { prompt: tuiPrompt }); });
309
312
  if (!created)
310
313
  throw new Error("Workspace wizard did not finish.");
311
314
  await changeWorkspace(created.slug, loadConfig());
@@ -361,6 +364,20 @@ export function App({ initial }) {
361
364
  setBusy(false);
362
365
  }
363
366
  return;
367
+ case "epic-approve":
368
+ setBusy(true);
369
+ try {
370
+ const { epic } = await approveEpic(config, action.id);
371
+ say("system", `Approved epic ${epic.id}: ${epic.title}`);
372
+ await refresh();
373
+ }
374
+ catch (error) {
375
+ setNotice(error instanceof Error ? error.message : String(error));
376
+ }
377
+ finally {
378
+ setBusy(false);
379
+ }
380
+ return;
364
381
  case "epics": {
365
382
  const rows = epicProgressRows(board.epics, board.tickets);
366
383
  say("system", rows.length
@@ -368,6 +385,20 @@ export function App({ initial }) {
368
385
  : "No epics.");
369
386
  return;
370
387
  }
388
+ case "paused":
389
+ setBusy(true);
390
+ try {
391
+ await setWorkspacePaused(config, action.paused);
392
+ say("system", `${workspace.slug} is now ${action.paused ? "off" : "on"}.`);
393
+ await refresh();
394
+ }
395
+ catch (error) {
396
+ setNotice(error instanceof Error ? error.message : String(error));
397
+ }
398
+ finally {
399
+ setBusy(false);
400
+ }
401
+ return;
371
402
  case "queue":
372
403
  setBusy(true);
373
404
  try {
@@ -431,20 +462,6 @@ export function App({ initial }) {
431
462
  }
432
463
  return;
433
464
  }
434
- case "plan":
435
- setBusy(true);
436
- try {
437
- await suspendTerminal(() => launchArchitect({ workspaceRepo: workspace.repo }));
438
- say("system", "Architect session ended. Review the spec, then run /epic new PATH.");
439
- await refresh();
440
- }
441
- catch (error) {
442
- setNotice(error instanceof Error ? error.message : String(error));
443
- }
444
- finally {
445
- setBusy(false);
446
- }
447
- return;
448
465
  case "decide": {
449
466
  const decision = board.decisions[0];
450
467
  if (!decision) {
@@ -491,8 +508,8 @@ export function App({ initial }) {
491
508
  default:
492
509
  return;
493
510
  }
494
- }, [view, settings, applyEdit, board, browsing, mode, say, askOrchestrator, order, settingsOrder, changeWorkspace,
495
- config, workspace.repo, refresh, suspendTerminal, exit]);
511
+ }, [view, settings, applyEdit, board, browsing, mode, say, askAgent, order, settingsOrder, changeWorkspace,
512
+ config, refresh, suspendTerminal, exit]);
496
513
  useInput((input, key) => {
497
514
  if (key.ctrl && input === "c")
498
515
  exit();
@@ -534,7 +551,7 @@ export function App({ initial }) {
534
551
  setDraft(next);
535
552
  if (editingRef.current)
536
553
  setEditing({ key: editingRef.current.key, draft: next });
537
- }, onSubmit: (value) => void run(value), isActive: !busy, placeholder: busy ? "working…" : "message, or /help", prompt: _jsx(Text, { color: mode === "browse" ? UI.dim : UI.cream, children: mode === "orchestrator" ? "orchestrator> " : "> " }), color: UI.text, onCancel: () => {
554
+ }, onSubmit: (value) => void run(value), isActive: !busy, placeholder: busy ? "working…" : "message, or /help", prompt: _jsx(Text, { color: mode === "browse" ? UI.dim : UI.cream, children: mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
538
555
  if (editing) {
539
556
  setEditing(null);
540
557
  setDraft("");
package/dist/tui/Help.js CHANGED
@@ -10,13 +10,16 @@ export const COMMANDS = [
10
10
  { name: "/ticket", args: "HD-12", help: "open one ticket" },
11
11
  { name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
12
12
  { name: "/cancel", args: "HD-12", help: "cancel a ticket" },
13
- { name: "/epic", args: "new PATH", help: "create an epic from a Markdown spec" },
13
+ { name: "/epic", args: "new PATH | approve ID", help: "create or approve a draft epic" },
14
14
  { name: "/epics", help: "list epics and ticket progress" },
15
- { name: "/plan", help: "author an epic with your Codex CLI" },
15
+ { name: "/architect", help: "talk to the agent that shapes draft epics" },
16
+ { name: "/plan", help: "alias for /architect" },
16
17
  { name: "/decide", args: "2 | text", help: "answer the decision on screen" },
17
18
  { name: "/agents", args: "[add ... | rm ROLE|ID]", help: "view or manage agents" },
18
19
  { name: "/settings", help: "change provider caps and agent settings" },
19
20
  { name: "/workspace", args: "[slug | new | set | rotate-key]", help: "list, switch, create, or configure" },
21
+ { name: "/on", help: "turn on the current workspace" },
22
+ { name: "/off", help: "turn off the current workspace" },
20
23
  { name: "/feed", help: "what just happened" },
21
24
  { name: "/orchestrator", help: "talk to the agent that gets work in flight finished" },
22
25
  { name: "/refresh", help: "reload the board now" },
package/dist/tui/data.js CHANGED
@@ -1,4 +1,4 @@
1
- import { answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, listTicketEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
1
+ import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, listTicketEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
2
2
  import { loadConfig, switchWorkspace as selectWorkspace, } from "../config.js";
3
3
  import { readEpicSpec } from "../epics.js";
4
4
  export const POLL_MS = 5_000;
@@ -106,8 +106,8 @@ export async function switchWorkspace(slug, config = loadConfig()) {
106
106
  selectWorkspace(slug);
107
107
  return snapshot;
108
108
  }
109
- export async function postOrchestrator(body, config) {
110
- await sendMessage({ body_md: body, to_role: "orchestrator", delivery: "queue" }, config);
109
+ export async function postAgentMessage(role, body, config) {
110
+ await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
111
111
  }
112
112
  export async function loadTicketDetail(config, key) {
113
113
  return showTicket(key, config);
@@ -115,23 +115,29 @@ export async function loadTicketDetail(config, key) {
115
115
  export async function createEpicFromFile(config, path) {
116
116
  return postEpic(await readEpicSpec(path), config);
117
117
  }
118
+ export async function approveEpic(config, id) {
119
+ return approveEpicNow(id, config);
120
+ }
118
121
  export async function queueTicket(config, key) {
119
122
  return queueTicketNow(key, config);
120
123
  }
121
124
  export async function cancelTicket(config, key) {
122
125
  return cancelTicketNow(key, config);
123
126
  }
127
+ export async function setWorkspacePaused(config, paused) {
128
+ return setPausedNow(paused, config);
129
+ }
124
130
  export async function createAgent(config, fields) {
125
131
  return postAgent(fields, config);
126
132
  }
127
133
  export async function deleteAgent(config, id) {
128
134
  return removeAgent(id, config);
129
135
  }
130
- export async function waitForReply(config, since, timeoutMs) {
136
+ export async function waitForReply(config, role, since, timeoutMs) {
131
137
  const deadline = Date.now() + timeoutMs;
132
138
  while (Date.now() <= deadline) {
133
139
  const { messages } = await listMessages({ since, limit: 50 }, config);
134
- const reply = messages.find((message) => ["orchestrator", "reviewer"].includes(message.from_role) &&
140
+ const reply = messages.find((message) => message.from_role === role &&
135
141
  ["human", "operator", "all"].includes(message.to_role));
136
142
  if (reply)
137
143
  return reply.body_md;
package/dist/tui/parse.js CHANGED
@@ -13,6 +13,10 @@ export function parseLine(raw) {
13
13
  switch (word.toLowerCase()) {
14
14
  case "orchestrator":
15
15
  return { kind: "mode", mode: "orchestrator" };
16
+ case "architect":
17
+ case "plan":
18
+ return rest.length ? { kind: "unknown", command: `${word.toLowerCase()} takes no arguments` }
19
+ : { kind: "mode", mode: "architect" };
16
20
  case "board":
17
21
  case "feed":
18
22
  case "inbox":
@@ -48,11 +52,18 @@ export function parseLine(raw) {
48
52
  ? { kind: "ticket", key: argument.toUpperCase() }
49
53
  : { kind: "unknown", command: "ticket needs a key" };
50
54
  case "epic":
51
- return rest[0]?.toLowerCase() === "new" && rest.length > 1
52
- ? { kind: "epic-new", path: rest.slice(1).join(" ") }
53
- : { kind: "unknown", command: "epic needs new PATH" };
55
+ if (rest[0]?.toLowerCase() === "new" && rest.length > 1) {
56
+ return { kind: "epic-new", path: rest.slice(1).join(" ") };
57
+ }
58
+ return rest[0]?.toLowerCase() === "approve" && rest.length === 2
59
+ ? { kind: "epic-approve", id: rest[1] }
60
+ : { kind: "unknown", command: "epic needs new PATH or approve ID" };
54
61
  case "epics":
55
62
  return { kind: "epics" };
63
+ case "on":
64
+ case "off":
65
+ return rest.length ? { kind: "unknown", command: `${word.toLowerCase()} takes no arguments` }
66
+ : { kind: "paused", paused: word.toLowerCase() === "off" };
56
67
  case "queue":
57
68
  return argument
58
69
  ? { kind: "queue", key: argument.toUpperCase() }
@@ -60,8 +71,6 @@ export function parseLine(raw) {
60
71
  case "cancel":
61
72
  return argument ? { kind: "cancel", key: argument.toUpperCase() }
62
73
  : { kind: "unknown", command: "cancel needs a key" };
63
- case "plan":
64
- return argument ? { kind: "unknown", command: "plan takes no arguments" } : { kind: "plan" };
65
74
  case "decide": {
66
75
  const dismiss = /(^|\s)--(skip|dismiss)(\s|$)/.test(argument);
67
76
  return {
package/dist/tui/theme.js CHANGED
@@ -40,11 +40,12 @@ export function speakerStyle(speaker) {
40
40
  case "you":
41
41
  return { borderStyle: DOTTED, borderColor: UI.cream, label: "you" };
42
42
  case "orchestrator":
43
+ case "architect":
43
44
  return {
44
45
  borderStyle: THIN,
45
46
  borderColor: UI.orchestratorBorder,
46
47
  backgroundColor: UI.orchestratorBg,
47
- label: "orchestrator",
48
+ label: speaker,
48
49
  };
49
50
  default:
50
51
  return { borderStyle: DOTTED, borderColor: UI.dim, label: "" };
@@ -1,6 +1,6 @@
1
- import { createInterface } from "node:readline/promises";
2
1
  import { createWorkspace, rotateWorkspaceApiKey, updateWorkspace } from "./api.js";
3
2
  import { loadConfig, writeHdConfig } from "./config.js";
3
+ import { promptOnStdin } from "./prompt.js";
4
4
  import { defaultGh, HDX_RUNNER_GH_USER, preflightWorkspace } from "./workspace-preflight.js";
5
5
  export const WORKSPACE_USAGE = "usage: hd workspace ls | new --name NAME --repo OWNER/NAME [--create|--no-create] [flags] | set [flags] | rotate-key";
6
6
  function flags(argv) {
@@ -44,12 +44,7 @@ async function repoState(repo, gh) {
44
44
  }
45
45
  }
46
46
  function lazyPrompt(deps) {
47
- let readline;
48
- const ask = deps.prompt ?? (async (question) => {
49
- readline ??= createInterface({ input: process.stdin, output: process.stdout });
50
- return readline.question(question);
51
- });
52
- return { ask, close: () => readline?.close() };
47
+ return deps.prompt ?? promptOnStdin;
53
48
  }
54
49
  async function askDefault(ask, label, fallback = "") {
55
50
  const answer = (await ask(`${label}${fallback ? ` [${fallback}]` : ""}: `)).trim();
@@ -65,7 +60,7 @@ export async function workspaceNew(argv, deps = {}) {
65
60
  throw new Error(WORKSPACE_USAGE);
66
61
  const gh = deps.gh ?? defaultGh;
67
62
  const prompt = lazyPrompt(deps);
68
- try {
63
+ {
69
64
  let name = opts.name ?? "";
70
65
  let repo = opts.repo ?? "";
71
66
  let branch = opts.branch;
@@ -79,7 +74,7 @@ export async function workspaceNew(argv, deps = {}) {
79
74
  }
80
75
  }
81
76
  if (guided) {
82
- name = await askDefault(prompt.ask, "Name", name);
77
+ name = await askDefault(prompt, "Name", name);
83
78
  if (!name)
84
79
  throw new Error("Name is required.");
85
80
  let defaultRepo = repo;
@@ -87,7 +82,7 @@ export async function workspaceNew(argv, deps = {}) {
87
82
  const owner = (await gh(["api", "user", "--jq", ".login"])).trim();
88
83
  defaultRepo = `${owner}/${workspaceSlug(name)}`;
89
84
  }
90
- repo = await askDefault(prompt.ask, "Repo", defaultRepo);
85
+ repo = await askDefault(prompt, "Repo", defaultRepo);
91
86
  if (!repo)
92
87
  throw new Error("Repo is required.");
93
88
  }
@@ -97,17 +92,17 @@ export async function workspaceNew(argv, deps = {}) {
97
92
  if (state && !state.found) {
98
93
  if (bools.has("no-create"))
99
94
  throw new Error(`GitHub repository ${repo} does not exist and --no-create was set.`);
100
- const create = bools.has("create") || (interactive && yes(await prompt.ask(`Create ${repo} as a private GitHub repo? [Y/n] `)));
95
+ const create = bools.has("create") || (interactive && yes(await prompt(`Create ${repo} as a private GitHub repo? [Y/n] `)));
101
96
  if (!create)
102
97
  throw new Error(`GitHub repository ${repo} was not created.`);
103
98
  await gh(["repo", "create", repo, "--private"]);
104
99
  state = { found: true, branch: "main" };
105
100
  }
106
101
  if (guided) {
107
- branch = await askDefault(prompt.ask, "Branch", branch ?? state?.branch ?? "main");
108
- host = await askDefault(prompt.ask, "Host", host);
102
+ branch = await askDefault(prompt, "Branch", branch ?? state?.branch ?? "main");
103
+ host = await askDefault(prompt, "Host", host);
109
104
  const summary = `${name} | ${repo} | ${branch} | host ${host}`;
110
- if (!yes(await prompt.ask(`Create ${summary}. Proceed? [Y/n] `)))
105
+ if (!yes(await prompt(`Create ${summary}. Proceed? [Y/n] `)))
111
106
  throw new Error("Workspace creation cancelled.");
112
107
  }
113
108
  const preflight = await (deps.preflightWorkspace ?? preflightWorkspace)({
@@ -122,9 +117,6 @@ export async function workspaceNew(argv, deps = {}) {
122
117
  runnerUser: opts["runner-user"] ?? HDX_RUNNER_GH_USER,
123
118
  initCommand: `hd init --url ${result.url} --api-key ${result.api_key} --slug ${result.workspace.slug}` };
124
119
  }
125
- finally {
126
- prompt.close();
127
- }
128
120
  }
129
121
  export async function workspaceSet(argv) {
130
122
  const { opts, bools, rest } = flags(argv);
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "hd": "dist/index.js"
7
7
  },
8
8
  "main": "dist/index.js",
9
9
  "files": [
10
- "dist",
11
- "prompts"
10
+ "dist"
12
11
  ],
13
12
  "scripts": {
14
13
  "build": "tsc",
15
14
  "test": "pnpm build && node --experimental-strip-types --test test/*.test.ts",
15
+ "test:pty": "pnpm build && expect test/tui-prompt.expect",
16
16
  "prepublishOnly": "npm run build"
17
17
  },
18
18
  "dependencies": {
package/dist/plan.js DELETED
@@ -1,94 +0,0 @@
1
- import { spawn, spawnSync } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { isAbsolute, join, resolve } from "node:path";
5
- import { createInterface } from "node:readline/promises";
6
- const ARCHITECT_PROMPT = new URL("../prompts/architect.md", import.meta.url);
7
- function gitOutput(cwd, args) {
8
- const result = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
9
- return result.status === 0 ? result.stdout.trim() : null;
10
- }
11
- export function remoteRepo(remote) {
12
- const trimmed = remote.trim().replace(/\/$/, "").replace(/\.git$/, "");
13
- const scp = trimmed.match(/^[^@]+@[^:]+:(.+)$/);
14
- if (scp)
15
- return scp[1] ?? null;
16
- try {
17
- const url = new URL(trimmed);
18
- return url.pathname.replace(/^\//, "") || null;
19
- }
20
- catch {
21
- return trimmed.includes("/") ? trimmed : null;
22
- }
23
- }
24
- function expanded(path) {
25
- const value = path.trim();
26
- if (value === "~")
27
- return homedir();
28
- if (value.startsWith("~/"))
29
- return join(homedir(), value.slice(2));
30
- return isAbsolute(value) ? value : resolve(value);
31
- }
32
- export function matchingWorkspaceRepo(directory, workspaceRepo) {
33
- const root = gitOutput(expanded(directory), ["rev-parse", "--show-toplevel"]);
34
- if (!root)
35
- return null;
36
- const origin = gitOutput(root, ["remote", "get-url", "origin"]);
37
- return origin && remoteRepo(origin)?.toLowerCase() === workspaceRepo.toLowerCase() ? root : null;
38
- }
39
- async function askForRepo(workspaceRepo) {
40
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
41
- throw new Error(`Current directory is not ${workspaceRepo}. Pass --repo DIR.`);
42
- }
43
- const prompt = createInterface({ input: process.stdin, output: process.stdout });
44
- try {
45
- return (await prompt.question(`Repository directory for ${workspaceRepo}: `)).trim();
46
- }
47
- finally {
48
- prompt.close();
49
- }
50
- }
51
- export async function resolveWorkspaceRepo(workspaceRepo, requested) {
52
- if (requested) {
53
- const match = matchingWorkspaceRepo(requested, workspaceRepo);
54
- if (!match)
55
- throw new Error(`${requested} is not a checkout of ${workspaceRepo}.`);
56
- return match;
57
- }
58
- const current = matchingWorkspaceRepo(process.cwd(), workspaceRepo);
59
- if (current)
60
- return current;
61
- const answer = await askForRepo(workspaceRepo);
62
- if (!answer)
63
- throw new Error("A repository directory is required.");
64
- const match = matchingWorkspaceRepo(answer, workspaceRepo);
65
- if (!match)
66
- throw new Error(`${answer} is not a checkout of ${workspaceRepo}.`);
67
- return match;
68
- }
69
- export function verifyCodex(checkVersion = () => spawnSync("codex", ["--version"], { stdio: "ignore" })) {
70
- const check = checkVersion();
71
- if (check.error || check.status !== 0) {
72
- throw new Error("Codex CLI is unavailable. Install it if needed, then run `codex login`.");
73
- }
74
- }
75
- function runCodex(cwd, prompt) {
76
- return new Promise((resolveRun, rejectRun) => {
77
- const child = spawn("codex", [prompt], { cwd, stdio: "inherit" });
78
- child.once("error", rejectRun);
79
- child.once("exit", (code, signal) => {
80
- if (code === 0)
81
- resolveRun();
82
- else
83
- rejectRun(new Error(`codex exited ${signal ? `with signal ${signal}` : `with status ${code ?? "unknown"}`}.`));
84
- });
85
- });
86
- }
87
- export async function launchArchitect(options, runtime = {}) {
88
- (runtime.verifyCodex ?? verifyCodex)();
89
- const [cwd, prompt] = await Promise.all([
90
- resolveWorkspaceRepo(options.workspaceRepo, options.repo),
91
- readFile(ARCHITECT_PROMPT, "utf8"),
92
- ]);
93
- await (runtime.invokeCodex ?? runCodex)(cwd, prompt);
94
- }
@@ -1,23 +0,0 @@
1
- You are helping the operator author an HDX epic for this repository.
2
-
3
- Start by interviewing the operator about the goal. Ask focused questions until the intended outcome, boundaries, and important tradeoffs are clear. Read the repository as needed so the plan reflects the code that actually exists.
4
-
5
- Draft the epic in this exact structure:
6
-
7
- # <title>
8
-
9
- ## Goal
10
-
11
- ## Scope
12
-
13
- ## Out of scope
14
-
15
- ## Acceptance
16
-
17
- ## Constraints
18
-
19
- The operator is the author. Let them review and revise the draft before saving it. Do not submit the epic to HDX yourself.
20
-
21
- Once the operator accepts the spec, write it to `docs/epics/<slug>.md`, where `<slug>` is a concise kebab-case name for the epic. End by printing the exact next command, with the real path substituted:
22
-
23
- `hd epic new docs/epics/<slug>.md`