@higherdev/cli 0.37.0 → 0.39.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
@@ -32,9 +32,10 @@ workspace-map config shapes are migrated automatically when they are read.
32
32
  | `hd ticket cancel KEY` | Cancel a ticket |
33
33
  | `hd epic new PATH [--title TITLE] [--draft]` | Create an approved epic, or keep it as a draft |
34
34
  | `hd epic list` | List epics and ticket progress |
35
- | `hd epic approve ID` | Open a draft epic for orchestrator decomposition |
35
+ | `hd epic approve ID` | Open a draft epic for Delivery Manager decomposition |
36
36
  | `hd epic rm ID` | Remove a draft epic |
37
- | `hd plan` | Point to the TUI `/architect` conversation |
37
+ | `hd product` (`hd plan`) | Point to the TUI Product Manager conversation |
38
+ | `hd architect` | Point to the TUI Architect design conversation |
38
39
  | `hd workspace ls` | List every workspace available to the configured key |
39
40
  | `hd workspace use SLUG` | Switch the current workspace |
40
41
  | `hd workspace new --name NAME --repo OWNER/NAME [options]` | Preflight GitHub, wire the runner, and create a paused workspace |
@@ -102,10 +103,10 @@ Use `hd host env set SUPABASE_ACCESS_TOKEN=... SUPABASE_ORG_ID=...` once to let
102
103
  Supabase for workspaces assigned to that host. Host values are never passed to builder processes.
103
104
  Use `hd host env set VERCEL_TOKEN=... VERCEL_TEAM_ID=...` to let the runner adopt or provision a linked
104
105
  Vercel project. It syncs `NEXT_PUBLIC_*`, names listed in `settings.vercel.env`, and existing Vercel-held names
105
- to production and preview. Later `hd env set` changes wake the orchestrator to update matching Vercel variables.
106
+ to production and preview. Later `hd env set` changes wake the Delivery Manager to update matching Vercel variables.
106
107
 
107
108
  Inside the TUI, use `/board`, `/inbox`, `/inbox more`, `/ticket`, `/ticket new [PATH.md]`, `/queue`, `/cancel`, `/msg`, `/logs`, `/epic new`,
108
- `/epic approve`, `/epic rm`, `/epics`, `/architect` (or `/plan`), `/decide`, `/agents add`, `/agents rm`, `/env`, `/settings`, `/workspace`,
109
+ `/epic approve`, `/epic rm`, `/epics`, `/product` (or `/plan`), `/architect`, `/decide`, `/agents add`, `/agents rm`, `/env`, `/settings`, `/workspace`,
109
110
  `/workspace new`, `/workspace set`, `/workspace rotate-key`, `/workspace grant-runner-access`, `/feed`,
110
111
  `/on`, `/off`, `/orchestrator`, `/refresh`, `/help`, or `/exit`. The display refreshes from
111
112
  the HDX API every five seconds.
@@ -2,7 +2,7 @@ import { createAgent, getStatus, listAgents } from "./api.js";
2
2
  import { promptOnStdin } from "./prompt.js";
3
3
  export const AGENT_ADD_USAGE = "usage: hd agents add [ROLE] --name NAME --provider P --model M --effort E";
4
4
  const EFFORTS = ["low", "medium", "high"];
5
- const ROLES = ["architect", "orchestrator", "reviewer", "builder"];
5
+ const ROLES = ["product", "architect", "orchestrator", "reviewer", "builder"];
6
6
  function parse(argv) {
7
7
  const opts = {};
8
8
  const rest = [];
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_(architect|orchestrator|reviewer)_idx/i)?.[1]?.toLowerCase();
7
+ const duplicate = raw.match(/agents_one_enabled_(product|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
  }
@@ -1,5 +1,5 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { isAbsolute, basename, extname } from "node:path";
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { isAbsolute, basename, extname, join } from "node:path";
3
3
  import { loadConfig } from "./config.js";
4
4
  const MIME = {
5
5
  ".avif": "image/avif", ".bmp": "image/bmp", ".gif": "image/gif", ".heic": "image/heic",
@@ -8,6 +8,9 @@ const MIME = {
8
8
  ".text": "text/plain", ".md": "text/markdown", ".markdown": "text/markdown",
9
9
  ".json": "application/json", ".csv": "text/csv",
10
10
  };
11
+ export const MAX_ATTACHMENTS_PER_DROP = 20;
12
+ export const PATH_DOES_NOT_EXIST = "That path does not exist";
13
+ const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
11
14
  export function formatAttachmentBytes(size) {
12
15
  if (size < 1024)
13
16
  return `${size} B`;
@@ -74,28 +77,46 @@ export function parseAttachArgs(argv) {
74
77
  }
75
78
  if (arg === "--to") {
76
79
  const role = argv[++at];
77
- if (role === "architect" || role === "orchestrator")
80
+ if (role === "product" || role === "architect" || role === "orchestrator")
78
81
  to = role;
79
82
  else
80
- throw new Error("--to must be architect or orchestrator.");
83
+ throw new Error("--to must be product, architect, or orchestrator.");
81
84
  continue;
82
85
  }
83
86
  rest.push(arg);
84
87
  }
85
- if (rest.length !== 1 || Boolean(ticketKey) === Boolean(to)) {
86
- throw new Error("usage: hd attach PATH --ticket KEY | --to architect|orchestrator");
88
+ if (!rest.length || Boolean(ticketKey) === Boolean(to)) {
89
+ throw new Error("usage: hd attach PATH [PATH...] --ticket KEY | --to product|architect|orchestrator");
87
90
  }
88
91
  if (ticketKey && !/^HD-[1-9][0-9]*$/i.test(ticketKey))
89
92
  throw new Error("--ticket must be an HD-N key.");
90
- return { path: rest[0], ...(ticketKey ? { ticketKey } : {}), ...(to ? { to } : {}) };
93
+ return { paths: rest, ...(ticketKey ? { ticketKey } : {}), ...(to ? { to } : {}) };
91
94
  }
92
95
  export function parseTuiAttach(text) {
93
96
  const words = shellWords(text.trim());
94
97
  if (!words?.length)
95
98
  return null;
96
- const key = words.at(-1)?.match(/^HD-[1-9][0-9]*$/i)?.[0].toUpperCase();
97
- const paths = key ? words.slice(0, -1) : words;
98
- return paths.length === 1 ? { path: paths[0], ...(key ? { ticketKey: key } : {}) } : null;
99
+ const rest = [];
100
+ let to;
101
+ for (let at = 0; at < words.length; at += 1) {
102
+ if (words[at] !== "--to") {
103
+ rest.push(words[at]);
104
+ continue;
105
+ }
106
+ const role = words[++at];
107
+ if (role !== "product" && role !== "architect" && role !== "orchestrator")
108
+ return null;
109
+ to = role;
110
+ }
111
+ const key = rest.at(-1)?.match(/^HD-[1-9][0-9]*$/i)?.[0].toUpperCase();
112
+ const paths = key ? rest.slice(0, -1) : rest;
113
+ if (!paths.length || (key && to))
114
+ return null;
115
+ return { paths, ...(key ? { ticketKey: key } : {}), ...(to ? { to } : {}) };
116
+ }
117
+ export function absolutePathInput(text) {
118
+ const words = shellWords(text.trim());
119
+ return words?.length && words.every(isAbsolute) ? words : [];
99
120
  }
100
121
  /**
101
122
  * Text that a Finder drop produces: one or more absolute paths, plain, quoted, or with
@@ -111,23 +132,80 @@ export function looksLikeDroppedPaths(text) {
111
132
  const firstWord = trimmed.split(/\s+/)[0].replace(/^['"]/, "");
112
133
  return firstWord.slice(1).includes("/") || /^['"]/.test(trimmed);
113
134
  }
114
- export async function detectDroppedPaths(pasted, isFile = async (path) => (await stat(path)).isFile()) {
115
- const text = pasted.trim();
116
- if (!text)
117
- return [];
118
- if (isAbsolute(text) && await isFile(text).catch(() => false))
119
- return [text];
120
- const words = shellWords(text);
121
- if (!words?.length || words.some((path) => !isAbsolute(path)))
122
- return [];
123
- const checks = await Promise.all(words.map((path) => isFile(path).catch(() => false)));
124
- return checks.every(Boolean) ? words : [];
135
+ async function collectFiles(input, files) {
136
+ const info = await stat(input);
137
+ if (info.isFile()) {
138
+ files.push(input);
139
+ return;
140
+ }
141
+ if (!info.isDirectory())
142
+ return;
143
+ const entries = await readdir(input, { withFileTypes: true });
144
+ entries.sort((left, right) => left.name.localeCompare(right.name));
145
+ for (const entry of entries) {
146
+ const child = join(input, entry.name);
147
+ if (entry.isDirectory())
148
+ await collectFiles(child, files);
149
+ else if (entry.isFile())
150
+ files.push(child);
151
+ }
152
+ }
153
+ export async function expandAttachmentPaths(inputs, limit = MAX_ATTACHMENTS_PER_DROP) {
154
+ const files = [];
155
+ const missing = [];
156
+ let existingInputs = 0;
157
+ for (const input of inputs) {
158
+ try {
159
+ await collectFiles(input, files);
160
+ existingInputs += 1;
161
+ }
162
+ catch (error) {
163
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
164
+ missing.push(input);
165
+ else
166
+ throw error;
167
+ }
168
+ }
169
+ const unique = [...new Set(files)].sort((left, right) => left.localeCompare(right));
170
+ const skipped = { unsupported: 0, tooLarge: 0, limit: 0 };
171
+ const allowed = [];
172
+ for (const file of unique) {
173
+ if (!MIME[extname(file).toLowerCase()]) {
174
+ skipped.unsupported += 1;
175
+ continue;
176
+ }
177
+ if ((await stat(file)).size > MAX_ATTACHMENT_BYTES) {
178
+ skipped.tooLarge += 1;
179
+ continue;
180
+ }
181
+ if (allowed.length >= limit) {
182
+ skipped.limit += 1;
183
+ continue;
184
+ }
185
+ allowed.push(file);
186
+ }
187
+ return { paths: allowed, missing, existingInputs, skipped };
188
+ }
189
+ export function attachmentSkipNote(expansion) {
190
+ const reasons = [];
191
+ if (expansion.skipped.unsupported)
192
+ reasons.push(`${expansion.skipped.unsupported} unsupported type${expansion.skipped.unsupported === 1 ? "" : "s"}`);
193
+ if (expansion.skipped.tooLarge)
194
+ reasons.push(`${expansion.skipped.tooLarge} over 20 MB`);
195
+ if (expansion.skipped.limit)
196
+ reasons.push(`${expansion.skipped.limit} beyond the 20-file limit`);
197
+ const count = expansion.skipped.unsupported + expansion.skipped.tooLarge + expansion.skipped.limit;
198
+ return count ? `Skipped ${count} file${count === 1 ? "" : "s"}: ${reasons.join(", ")}.` : null;
199
+ }
200
+ export async function detectDroppedPaths(pasted) {
201
+ const paths = absolutePathInput(pasted);
202
+ return paths.length ? (await expandAttachmentPaths(paths)).paths : [];
125
203
  }
126
204
  export async function uploadAttachment(path, target = {}, config = loadConfig(), fetchImpl = fetch) {
127
205
  const info = await stat(path);
128
206
  if (!info.isFile())
129
207
  throw new Error(`${path} is not a file.`);
130
- if (info.size > 20 * 1024 * 1024)
208
+ if (info.size > MAX_ATTACHMENT_BYTES)
131
209
  throw new Error("Attachments must be 20 MB or smaller.");
132
210
  const mime = MIME[extname(path).toLowerCase()];
133
211
  if (!mime)
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import { banner, c, statusChip, table, truncate, usage } from "./out.js";
13
13
  import { ticketNew } from "./ticket-commands.js";
14
14
  import { ticketViewLines } from "./tui/ticket-view.js";
15
15
  import { agentAdd, AGENT_ADD_USAGE } from "./agent-commands.js";
16
- import { attachmentChip, parseAttachArgs, uploadAttachment } from "./attachments.js";
16
+ import { attachmentChip, attachmentSkipNote, expandAttachmentPaths, parseAttachArgs, PATH_DOES_NOT_EXIST, uploadAttachment } from "./attachments.js";
17
17
  import { WORKSPACE_USAGE, workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
18
18
  import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, requireAdminRepo, runnerRepoPermission } from "./workspace-preflight.js";
19
19
  function fail(message) {
@@ -261,10 +261,10 @@ async function cmdRoadmap(argv) {
261
261
  for (const line of roadmapText(data.epics))
262
262
  console.log(line);
263
263
  }
264
- async function cmdPlan(argv) {
264
+ async function cmdChat(argv, role) {
265
265
  if (argv.length)
266
- fail("usage: hd plan");
267
- console.log("Open the HDX TUI and use /architect (or /plan).");
266
+ fail(`usage: hd ${role}`);
267
+ console.log(`Open the HDX TUI and use /${role}${role === "product" ? " (or /plan)" : ""}.`);
268
268
  }
269
269
  async function cmdLogs(argv) {
270
270
  const { rest, bools } = flags(argv);
@@ -309,14 +309,26 @@ async function cmdMsg(argv) {
309
309
  });
310
310
  console.log(`sent ${message.id}`);
311
311
  }
312
- export async function cmdAttach(argv) {
312
+ export async function cmdAttach(argv, deps = {}) {
313
313
  const parsed = parseAttachArgs(argv);
314
- const attachment = await uploadAttachment(parsed.path, parsed.ticketKey ? { ticketKey: parsed.ticketKey } : {});
315
- if (parsed.to) {
316
- await postMessage({ body_md: `Attached ${attachment.name}.`, to_role: parsed.to, delivery: "queue",
317
- attachment_ids: [attachment.id] });
318
- }
319
- console.log(`${attachmentChip(attachment)}${parsed.ticketKey ? ` attached to ${parsed.ticketKey}` : ` sent to ${parsed.to}`}`);
314
+ const expansion = await expandAttachmentPaths(parsed.paths);
315
+ if (expansion.missing.length)
316
+ throw new Error(PATH_DOES_NOT_EXIST);
317
+ const note = attachmentSkipNote(expansion);
318
+ if (!expansion.paths.length)
319
+ throw new Error(["No supported files found.", note].filter(Boolean).join(" "));
320
+ const config = deps.config ?? loadConfig();
321
+ const attachments = [];
322
+ for (const path of expansion.paths)
323
+ attachments.push(await uploadAttachment(path, {}, config, deps.fetch));
324
+ await (deps.postMessage ?? postMessage)({ body_md: `Attached ${attachments.length} files`,
325
+ to_role: parsed.to ?? "builder", delivery: "queue", ...(parsed.ticketKey ? { ticket_key: parsed.ticketKey } : {}),
326
+ attachment_ids: attachments.map((item) => item.id) }, config);
327
+ for (const attachment of attachments)
328
+ console.log(attachmentChip(attachment));
329
+ console.log(`Attached ${attachments.length} files${parsed.ticketKey ? ` to ${parsed.ticketKey}` : ` to ${parsed.to}`}.`);
330
+ if (note)
331
+ console.log(note);
320
332
  }
321
333
  async function cmdInbox(argv) {
322
334
  const { rest, opts, bools } = flags(argv);
@@ -713,8 +725,12 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
713
725
  await cmdRoadmap(rest);
714
726
  return;
715
727
  }
716
- if (cmd === "plan") {
717
- await cmdPlan(rest);
728
+ if (cmd === "plan" || cmd === "product") {
729
+ await cmdChat(rest, "product");
730
+ return;
731
+ }
732
+ if (cmd === "architect") {
733
+ await cmdChat(rest, "architect");
718
734
  return;
719
735
  }
720
736
  if (cmd === "logs") {
package/dist/out.js CHANGED
@@ -68,7 +68,7 @@ export function usage() {
68
68
  ` ${c.blue("hd runs | hd run cancel ID")} inspect or cancel live runs`,
69
69
  ` ${c.blue("hd epic new PATH [--draft] | list | approve | rm")} epic operations`,
70
70
  ` ${c.blue("hd roadmap [--json]")} ordered workspace roadmap`,
71
- ` ${c.blue("hd plan")} use /architect in the TUI`,
71
+ ` ${c.blue("hd product | hd architect")} open a role chat in the TUI`,
72
72
  ` ${c.blue("hd workspace ls | use | new | set | rotate-key | grant-runner-access")} workspace operations`,
73
73
  ` ${c.blue("hd agents [add | rm | set]")} manage agents`,
74
74
  ` ${c.blue("hd caps [set PROVIDER N]")} provider concurrency`,
@@ -76,7 +76,7 @@ export function usage() {
76
76
  ` ${c.blue("hd host runner-install | roll | env")} install CI, roll, or edit host env`,
77
77
  ` ${c.blue("hd logs KEY [-f]")} run events`,
78
78
  ` ${c.blue("hd msg KEY TEXT")} message a builder`,
79
- ` ${c.blue("hd attach PATH --ticket KEY | --to ROLE")} attach a file`,
79
+ ` ${c.blue("hd attach PATH... --ticket KEY | --to ROLE")} attach files or a folder`,
80
80
  ` ${c.blue("hd inbox [--all] [--limit N] [--json]")} inbox messages`,
81
81
  ` ${c.blue("hd decide [ID --answer TEXT]")} decisions`,
82
82
  ` ${c.blue("hd on | hd off")} workspace switch`,
package/dist/tui/App.js CHANGED
@@ -33,7 +33,7 @@ import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./setti
33
33
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
34
34
  import { UI } from "./theme.js";
35
35
  import { WorkspaceLoads } from "./workspace-load.js";
36
- import { attachmentChip, detectDroppedPaths, looksLikeDroppedPaths, uploadAttachment } from "../attachments.js";
36
+ import { absolutePathInput, attachmentChip, attachmentSkipNote, expandAttachmentPaths, looksLikeDroppedPaths, MAX_ATTACHMENTS_PER_DROP, PATH_DOES_NOT_EXIST, uploadAttachment } from "../attachments.js";
37
37
  let messageSeq = 0;
38
38
  const nextId = () => `m${messageSeq++}`;
39
39
  const tuiPrompt = (question) => promptOnStdin(question, true);
@@ -456,31 +456,43 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
456
456
  }
457
457
  })();
458
458
  }, [agentName, config, pendingAttachments, setThread, workspace.id]);
459
+ const uploadExpanded = useCallback(async (inputs, target = {}, prepared, limit = MAX_ATTACHMENTS_PER_DROP) => {
460
+ const expansion = prepared ?? await expandAttachmentPaths(inputs, limit);
461
+ if (expansion.missing.length)
462
+ throw new Error(PATH_DOES_NOT_EXIST);
463
+ const note = attachmentSkipNote(expansion);
464
+ if (!expansion.paths.length)
465
+ throw new Error(["No supported files found.", note].filter(Boolean).join(" "));
466
+ const uploaded = [];
467
+ for (const path of expansion.paths) {
468
+ try {
469
+ uploaded.push(await uploadAttachment(path, { ...target, onProgress: (progress) => setAttachmentUploads((current) => ({ ...current, [path]: progress })) }, config));
470
+ }
471
+ finally {
472
+ setAttachmentUploads((current) => {
473
+ const next = { ...current };
474
+ delete next[path];
475
+ return next;
476
+ });
477
+ }
478
+ }
479
+ return { uploaded, note };
480
+ }, [config, pendingAttachments.length]);
459
481
  const receiveDrop = useCallback((pasted) => {
460
482
  if (!looksLikeDroppedPaths(pasted))
461
483
  return false;
462
484
  void (async () => {
463
- const paths = await detectDroppedPaths(pasted);
464
- if (!paths.length) {
485
+ const inputs = absolutePathInput(pasted);
486
+ if (!inputs.length) {
465
487
  setDraft((current) => current + pasted.replace(/[\r\n]+/g, " "));
466
488
  return;
467
489
  }
468
490
  setBusy(true);
469
491
  try {
470
- const uploaded = [];
471
- for (const path of paths) {
472
- try {
473
- uploaded.push(await uploadAttachment(path, { onProgress: (progress) => setAttachmentUploads((current) => ({ ...current, [path]: progress })) }, config));
474
- }
475
- finally {
476
- setAttachmentUploads((current) => {
477
- const next = { ...current };
478
- delete next[path];
479
- return next;
480
- });
481
- }
482
- }
492
+ const { uploaded, note } = await uploadExpanded(inputs, {}, undefined, Math.max(0, MAX_ATTACHMENTS_PER_DROP - pendingAttachments.length));
483
493
  setPendingAttachments((current) => [...current, ...uploaded]);
494
+ if (note)
495
+ setNotice(note);
484
496
  }
485
497
  catch (error) {
486
498
  setNotice(error instanceof Error ? error.message : String(error));
@@ -490,7 +502,7 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
490
502
  }
491
503
  })();
492
504
  return true;
493
- }, [config]);
505
+ }, [uploadExpanded]);
494
506
  const openTicket = useCallback(async (key) => {
495
507
  setTicketKey(key);
496
508
  setView("ticket");
@@ -520,11 +532,27 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
520
532
  }, [view, ticketKey, visibleStory?.latest_headline, visibleStory?.timeline, config]);
521
533
  const run = useCallback(async (raw) => {
522
534
  const text = raw.trim();
523
- // A dropped path that reached the line as typed text is an attachment, not a command.
524
- if (looksLikeDroppedPaths(text) && (await detectDroppedPaths(text)).length) {
525
- setDraft("");
526
- receiveDrop(text);
527
- return;
535
+ // An existing absolute path always wins over slash-command parsing in every view.
536
+ const absolutePaths = absolutePathInput(text);
537
+ if (absolutePaths.length) {
538
+ const expansion = await expandAttachmentPaths(absolutePaths, Math.max(0, 20 - pendingAttachments.length));
539
+ if (expansion.existingInputs) {
540
+ setDraft("");
541
+ setBusy(true);
542
+ try {
543
+ const { uploaded, note } = await uploadExpanded(absolutePaths, {}, expansion);
544
+ setPendingAttachments((current) => [...current, ...uploaded]);
545
+ if (note)
546
+ setNotice(note);
547
+ }
548
+ catch (error) {
549
+ setNotice(error instanceof Error ? error.message : String(error));
550
+ }
551
+ finally {
552
+ setBusy(false);
553
+ }
554
+ return;
555
+ }
528
556
  }
529
557
  if (view === "settings") {
530
558
  const open = editingRef.current;
@@ -588,11 +616,15 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
588
616
  setDraft("");
589
617
  setNotice(null);
590
618
  const action = parseLine(text);
619
+ if (absolutePaths.length && action.kind === "unknown") {
620
+ setNotice(PATH_DOES_NOT_EXIST);
621
+ return;
622
+ }
591
623
  if (action.kind === "say") {
592
624
  if (mode !== "browse")
593
625
  askAgent(mode, text);
594
626
  else
595
- setNotice("Use /architect or /orchestrator before sending a message.");
627
+ setNotice("Use /product, /architect, or /orchestrator before sending a message.");
596
628
  return;
597
629
  }
598
630
  switch (action.kind) {
@@ -886,12 +918,26 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
886
918
  case "attach":
887
919
  setBusy(true);
888
920
  try {
889
- const key = action.key ?? ticketKey;
890
- if (!key)
891
- throw new Error("Open a ticket or include its HD-N key.");
892
- const attachment = await uploadAttachment(action.path, { ticketKey: key }, config);
893
- say("system", `${attachmentChip(attachment)} attached to ${key}.`);
894
- await refresh();
921
+ const key = action.key ?? (view === "ticket" ? ticketKey : null);
922
+ if (!key && !action.to && view !== "chat") {
923
+ throw new Error("Choose a target: open a ticket, add HD-N, or use --to product|architect|orchestrator.");
924
+ }
925
+ const queuesForChat = !key && !action.to;
926
+ const { uploaded, note } = await uploadExpanded(action.paths, key ? { ticketKey: key } : {}, undefined, queuesForChat ? Math.max(0, MAX_ATTACHMENTS_PER_DROP - pendingAttachments.length) : MAX_ATTACHMENTS_PER_DROP);
927
+ if (action.to) {
928
+ await postAgentMessage(action.to, `Attached ${uploaded.length} files`, config, uploaded.map((attachment) => attachment.id));
929
+ say("system", `Attached ${uploaded.length} files to ${action.to}.`);
930
+ }
931
+ else if (key) {
932
+ say("system", `Attached ${uploaded.length} files to ${key}.`);
933
+ await refresh();
934
+ }
935
+ else {
936
+ setPendingAttachments((current) => [...current, ...uploaded]);
937
+ say("system", `Attached ${uploaded.length} files to your next ${mode} message.`);
938
+ }
939
+ if (note)
940
+ setNotice(note);
895
941
  }
896
942
  catch (error) {
897
943
  setNotice(error instanceof Error ? error.message : String(error));
@@ -956,7 +1002,8 @@ export function App({ initial, availableUpdate: initialUpdate = null, updateDeps
956
1002
  }
957
1003
  }, [view, settings, applyEdit, board, browsing, mode, say, askAgent, openAgentChat, order, settingsOrder,
958
1004
  changeWorkspace, config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering,
959
- selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width, pendingAttachments]);
1005
+ selectedDecisionId, submitDecision, ticketKey, ticketCollapsed, ticketOffset, width, pendingAttachments,
1006
+ uploadExpanded]);
960
1007
  useInput((input, key) => {
961
1008
  if (key.ctrl && input === "c") {
962
1009
  exit();
package/dist/tui/Help.js CHANGED
@@ -13,12 +13,13 @@ export const COMMANDS = [
13
13
  { name: "/cancel", args: "HD-12 | RUN-ID", help: "cancel a ticket or run" },
14
14
  { name: "/merge", args: "HD-12", help: "approve a reviewed PR over the reviewer's objections" },
15
15
  { name: "/msg", args: "HD-12 TEXT", help: "message a ticket's builder" },
16
- { name: "/attach", args: "PATH [HD-12]", help: "attach a file to the open or named ticket" },
16
+ { name: "/attach", args: "PATH... [HD-12 | --to ROLE]", help: "attach files, or queue them for this chat" },
17
17
  { name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
18
18
  { name: "/epic", args: "new PATH [--draft] | approve ID | rm ID", help: "create, approve, or remove an epic" },
19
19
  { name: "/epics", help: "list epics and ticket progress" },
20
- { name: "/architect", help: "open a chat with the architect" },
21
- { name: "/plan", help: "alias for /architect" },
20
+ { name: "/product", help: "open a chat with the Product Manager" },
21
+ { name: "/plan", help: "one-release alias for /product" },
22
+ { name: "/architect", help: "open a technical design chat with the Architect" },
22
23
  { name: "/decide", args: "[N|ID] [answer]", help: "answer the selected or numbered decision" },
23
24
  { name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
24
25
  { name: "/env", help: "list workspace environment variable names" },
@@ -27,7 +28,7 @@ export const COMMANDS = [
27
28
  { name: "/on", help: "turn on the current workspace" },
28
29
  { name: "/off", help: "turn off the current workspace" },
29
30
  { name: "/feed", help: "what just happened" },
30
- { name: "/orchestrator", help: "open a chat with the orchestrator" },
31
+ { name: "/orchestrator", help: "open a chat with the Delivery Manager" },
31
32
  { name: "/refresh", help: "reload the board now" },
32
33
  { name: "/help", help: "this list" },
33
34
  { name: "/exit", help: "leave" },
@@ -8,7 +8,7 @@ import { wrapLines } from "./ticket-view.js";
8
8
  import { UI } from "./theme.js";
9
9
  export function roadmapLines(board, width) {
10
10
  if (!board.roadmap)
11
- return ["No roadmap yet. Talk to /architect to create one."];
11
+ return ["No roadmap yet. Talk to /product to create one."];
12
12
  const lines = ["Vision"];
13
13
  lines.push(...wrapLines(board.roadmap.vision_md, Math.max(12, width - 2)));
14
14
  lines.push("");
@@ -21,6 +21,7 @@ export function signedOutProviders(board) {
21
21
  return providers;
22
22
  }
23
23
  const roleForKind = {
24
+ product: "product",
24
25
  architect: "architect",
25
26
  build: "builder",
26
27
  followup: "builder",
@@ -29,7 +29,7 @@ export function chatAgentLabel(role, displayName) {
29
29
  const named = displayName?.trim();
30
30
  if (named)
31
31
  return named;
32
- return role === "architect" ? "Architect" : "Orchestrator";
32
+ return role === "product" ? "Product Manager" : role === "architect" ? "Architect" : "Delivery Manager";
33
33
  }
34
34
  export function formatWorkingElapsed(ms) {
35
35
  const seconds = Math.max(0, Math.floor(ms / 1000));
package/dist/tui/data.js CHANGED
@@ -144,7 +144,7 @@ export async function loadChatMessages(config, role) {
144
144
  return messages.slice().reverse();
145
145
  }
146
146
  export async function followChat(config, role, messageId, since) {
147
- const kind = role === "architect" ? "architect" : "orchestrate";
147
+ const kind = role === "orchestrator" ? "orchestrate" : role;
148
148
  const status = await getStatus(config);
149
149
  const runs = [...(status.live_runs ?? []), ...(status.recent_runs ?? [])];
150
150
  const tagged = runs.find((run) => run.message_id === messageId);
package/dist/tui/parse.js CHANGED
@@ -15,9 +15,12 @@ export function parseLine(raw) {
15
15
  case "orchestrator":
16
16
  return { kind: "mode", mode: "orchestrator" };
17
17
  case "architect":
18
+ return rest.length ? { kind: "unknown", command: "architect takes no arguments" }
19
+ : { kind: "mode", mode: "architect" };
20
+ case "product":
18
21
  case "plan":
19
22
  return rest.length ? { kind: "unknown", command: `${word.toLowerCase()} takes no arguments` }
20
- : { kind: "mode", mode: "architect" };
23
+ : { kind: "mode", mode: "product" };
21
24
  case "board":
22
25
  case "roadmap":
23
26
  case "feed":
@@ -105,8 +108,8 @@ export function parseLine(raw) {
105
108
  : { kind: "unknown", command: "msg needs KEY TEXT" };
106
109
  case "attach": {
107
110
  const parsed = parseTuiAttach(argument);
108
- return parsed ? { kind: "attach", path: parsed.path, key: parsed.ticketKey ?? null }
109
- : { kind: "unknown", command: "attach needs PATH and an optional HD-N key" };
111
+ return parsed ? { kind: "attach", paths: parsed.paths, key: parsed.ticketKey ?? null, to: parsed.to ?? null }
112
+ : { kind: "unknown", command: "attach needs PATH [PATH...] and HD-N or --to product|architect|orchestrator" };
110
113
  }
111
114
  case "logs":
112
115
  if (rest[0]?.toLowerCase() === "raw" && rest.length <= 2) {
package/dist/tui/theme.js CHANGED
@@ -40,6 +40,7 @@ export function speakerStyle(speaker) {
40
40
  case "you":
41
41
  return { borderStyle: DOTTED, borderColor: UI.cream, label: "you" };
42
42
  case "orchestrator":
43
+ case "product":
43
44
  case "architect":
44
45
  return {
45
46
  borderStyle: THIN,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@higherdev/cli",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",