quoxcli 0.4.0 → 0.6.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.
Files changed (199) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -28,6 +28,10 @@ import { printSuccess } from '../core/output.js';
28
28
  import { events, printEvent, celebrate, line as glyphLine } from '../core/quoxglyph.js';
29
29
  import { resolveEndpoint } from '../api/endpoints.js';
30
30
  import { QuoxApiError } from '../core/errors.js';
31
+ import {
32
+ statusGlyph, STATUS_LEGEND, pad, truncate, idWidth, relativeAge, humanDate, termWidth,
33
+ renderBrief, renderFocusCapped, diffBriefs, renderTicker, capNotes, capPlan, capNoteBody,
34
+ } from '../render/planBrief.js';
31
35
 
32
36
  const VALID_LENSES = ['focus', 'tier', 'all'];
33
37
  const VALID_STATUSES = ['NOT_STARTED', 'IN_PROGRESS', 'COMPLETE', 'BLOCKED', 'DECISION_NEEDED'];
@@ -38,17 +42,8 @@ const WEEK_WIDTH = 4; // characters of bar per week, timeline view
38
42
 
39
43
  // ── glyphs ───────────────────────────────────────────────────────────────
40
44
 
41
- const STATUS_GLYPH = {
42
- NOT_STARTED: { glyph: '◇', color: chalk.dim },
43
- IN_PROGRESS: { glyph: '⬗', color: chalk.yellow },
44
- BLOCKED: { glyph: '◆', color: chalk.red },
45
- DECISION_NEEDED: { glyph: '◈', color: chalk.magenta },
46
- COMPLETE: { glyph: '◆', color: chalk.dim },
47
- };
48
-
49
- function statusGlyph(status) {
50
- return STATUS_GLYPH[status] || { glyph: '◇', color: chalk.dim };
51
- }
45
+ // STATUS_GLYPH / statusGlyph live in ../render/planBrief.js (shared with the
46
+ // brief, focus and watch renderers). COMPLETE is ✔ there, distinct from BLOCKED ◆.
52
47
 
53
48
  const PHASE_GLYPH = {
54
49
  TODO: { glyph: '□', color: chalk.dim },
@@ -66,16 +61,7 @@ function phaseGlyph(status) {
66
61
  // string with padEnd/padStart would count the escape bytes as visible width
67
62
  // and break column alignment.
68
63
 
69
- function pad(value, n) {
70
- const s = String(value ?? '');
71
- return s.length >= n ? s : s + ' '.repeat(n - s.length);
72
- }
73
-
74
- function truncate(value, n) {
75
- const s = String(value ?? '');
76
- if (s.length <= n) return s;
77
- return s.slice(0, Math.max(0, n - 1)) + '…';
78
- }
64
+ // pad() and truncate() are imported from ../render/planBrief.js.
79
65
 
80
66
  function wrapText(text, width) {
81
67
  const words = String(text || '').split(/\s+/);
@@ -125,7 +111,7 @@ function renderRoadmapsList(roadmaps) {
125
111
  const lines = [];
126
112
  for (const r of roadmaps) {
127
113
  lines.push('');
128
- lines.push(chalk.cyan.bold(` ${r.id}`) + ' ' + r.name);
114
+ lines.push(chalk.cyan.bold(` ${r.id}`) + ' ' + r.name + (r.archived_at ? ' ' + chalk.yellow('archived') : ''));
129
115
  if (r.description) lines.push(chalk.dim(` ${r.description}`));
130
116
  const counts = r.status_counts || {};
131
117
  const parts = Object.entries(counts).map(([status, n]) => {
@@ -141,12 +127,13 @@ function renderSearchResults(data) {
141
127
  const results = data.results || [];
142
128
  if (results.length === 0) return [chalk.dim(` No streams match "${data.q}".`)];
143
129
  const lines = [''];
130
+ const idW = idWidth(results);
144
131
  for (const r of results) {
145
132
  const g = statusGlyph(r.status);
146
133
  const where = [...new Set((r.matches || []).map((m) => m.where))].join(', ');
147
134
  const first = (r.matches || []).find((m) => m.snippet) || {};
148
135
  lines.push(
149
- ` ${g.color(g.glyph)} ${pad(truncate(r.id, ID_WIDTH), ID_WIDTH)} ${pad(truncate(r.name, NAME_WIDTH), NAME_WIDTH)} `
136
+ ` ${g.color(g.glyph)} ${chalk.cyan(pad(truncate(r.id, idW), idW))} ${pad(truncate(r.name, NAME_WIDTH), NAME_WIDTH)} `
150
137
  + `${pad(r.status, 15)} ${pad(where, 18)} ${chalk.dim(truncate(first.snippet || '', 60))}`
151
138
  );
152
139
  }
@@ -154,10 +141,12 @@ function renderSearchResults(data) {
154
141
  return lines;
155
142
  }
156
143
 
157
- function streamLine(s, { indent = 0, showReasons = false } = {}) {
144
+ function streamLine(s, { indent = 0, showReasons = false, idW = ID_WIDTH } = {}) {
158
145
  const prefix = indent > 0 ? ' '.repeat(indent) + '└ ' : ' ';
159
146
  const g = statusGlyph(s.status);
160
- const idPlain = pad(s.id, ID_WIDTH);
147
+ // Substream rows are shifted right by their prefix, so shrink their id pad
148
+ // by the same amount to keep the name column aligned with top-level rows.
149
+ const idPlain = pad(s.id, Math.max(0, idW - (prefix.length - 2)));
161
150
  const idStr = indent > 0 ? chalk.dim(idPlain) : chalk.cyan(idPlain);
162
151
  const nameStr = pad(truncate(s.name, NAME_WIDTH - 1), NAME_WIDTH);
163
152
  const tierStr = chalk.dim(`T${s.tier}`);
@@ -176,10 +165,21 @@ function streamAndSubstreams(s, opts) {
176
165
  return lines;
177
166
  }
178
167
 
168
+ /** Id column width for a stream list: widest id plus its substream indent, never below the old 18. */
169
+ function listIdWidth(tops) {
170
+ let w = ID_WIDTH;
171
+ for (const s of tops) {
172
+ w = Math.max(w, String(s.id).length);
173
+ for (const sub of s.substreams || []) w = Math.max(w, String(sub.id).length + 2);
174
+ }
175
+ return w;
176
+ }
177
+
179
178
  /** lens: 'tier' | 'focus' | 'all' — matches the API's `streams` payload shape for that lens. */
180
179
  function renderStreamList(payload, { lens = 'tier' } = {}) {
181
180
  const lines = [];
182
181
  const streams = (payload && payload.streams) || [];
182
+ const idW = listIdWidth(lens === 'tier' ? streams.flatMap((g) => g.streams || []) : streams);
183
183
 
184
184
  if (lens === 'tier') {
185
185
  if (streams.length === 0) {
@@ -190,7 +190,7 @@ function renderStreamList(payload, { lens = 'tier' } = {}) {
190
190
  lines.push('');
191
191
  lines.push(chalk.cyan.bold(` TIER ${group.tier}`));
192
192
  for (const s of group.streams) {
193
- lines.push(...streamAndSubstreams(s, { indent: 0 }));
193
+ lines.push(...streamAndSubstreams(s, { indent: 0, idW }));
194
194
  }
195
195
  }
196
196
  return lines;
@@ -202,14 +202,30 @@ function renderStreamList(payload, { lens = 'tier' } = {}) {
202
202
  return lines;
203
203
  }
204
204
  for (const s of streams) {
205
- lines.push(...streamAndSubstreams(s, { indent: 0, showReasons: lens === 'focus' }));
205
+ lines.push(...streamAndSubstreams(s, { indent: 0, showReasons: lens === 'focus', idW }));
206
206
  }
207
207
  return lines;
208
208
  }
209
209
 
210
+ /** Honest count of what was printed, plus a one-line glyph legend (the list itself prints glyphs only). */
211
+ function renderListFooter(payload, lens) {
212
+ const streams = (payload && payload.streams) || [];
213
+ if (streams.length === 0) return [];
214
+ const tiers = lens === 'tier' ? streams : null;
215
+ const top = tiers ? tiers.flatMap((g) => g.streams || []) : streams;
216
+ const subs = top.reduce((n, s) => n + (s.substreams || []).length, 0);
217
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
218
+ const count = `${plural(top.length, 'stream')}${subs ? ` + ${plural(subs, 'substream')}` : ''}${tiers ? ` in ${plural(tiers.length, 'tier')}` : ''}`;
219
+ return [
220
+ '',
221
+ chalk.dim(` ${count}`),
222
+ chalk.dim(` ${STATUS_LEGEND} └ substream`),
223
+ ];
224
+ }
225
+
210
226
  // ── render: stream detail ───────────────────────────────────────────────
211
227
 
212
- function renderStreamDetail(detail) {
228
+ function renderStreamDetail(detail, { full = false, now = Date.now() } = {}) {
213
229
  const { stream, phases = [], plan, notes = [], depends_on: dependsOn = [], dependents = [] } = detail;
214
230
  const lines = [];
215
231
  const g = statusGlyph(stream.status);
@@ -258,18 +274,23 @@ function renderStreamDetail(detail) {
258
274
  }
259
275
 
260
276
  if (plan && plan.body) {
277
+ const { body, hidden } = capPlan(plan.body, { full });
261
278
  lines.push('');
262
279
  lines.push(chalk.cyan.bold(` Plan (v${plan.version})`));
263
280
  lines.push('');
264
- lines.push(renderMarkdownLite(plan.body));
281
+ lines.push(renderMarkdownLite(body));
282
+ if (hidden) lines.push(chalk.dim(` +${hidden} more lines, --full to print the whole plan`));
265
283
  }
266
284
 
267
285
  if (notes.length) {
286
+ const { notes: shown, hidden } = capNotes(notes, { full });
268
287
  lines.push('');
269
- lines.push(chalk.cyan.bold(' Notes'));
270
- for (const n of notes) {
271
- lines.push(` ${chalk.dim(n.created_at)} ${n.author ? chalk.dim(`(${n.author}) `) : ''}${n.body}`);
288
+ lines.push(chalk.cyan.bold(' Notes') + chalk.dim(full ? ` ${notes.length}, newest first` : ` newest ${shown.length} of ${notes.length}`));
289
+ for (const n of shown) {
290
+ const stamp = `${String(n.created_at || '').slice(0, 10)} ${relativeAge(n.created_at, now).padStart(3)}`;
291
+ lines.push(` ${chalk.dim(stamp)} ${n.author ? chalk.dim(`(${n.author}) `) : ''}${capNoteBody(n.body, { full })}`);
272
292
  }
293
+ if (hidden) lines.push(chalk.dim(` +${hidden} older notes, --full to print every note`));
273
294
  }
274
295
 
275
296
  lines.push('');
@@ -367,6 +388,60 @@ function renderTimeline(timeline, opts = {}) {
367
388
  return lines;
368
389
  }
369
390
 
391
+ // ── render: follow-ups (server: action items) ───────────────────────────
392
+ // Named "follow-ups" in the CLI (owner 2026-11-08) so they do not collide with
393
+ // the Action Tracker's `quox actions`; the server routes stay /action-items.
394
+ // The legacy hidden `plan actions` / `plan action ...` aliases keep the old
395
+ // "action item" wording. Rows from quoxplan/server/src/api/actionItems.js (rowToActionItem, plus
396
+ // stream_name/stream_status on the roadmap-wide list). kind 'manual' was
397
+ // added by a person; 'blocked'/'decision' were raised automatically when the
398
+ // stream entered BLOCKED / DECISION_NEEDED, and close when it leaves.
399
+
400
+ const VALID_ACTION_STATUSES = ['OPEN', 'DONE', 'CANCELLED'];
401
+
402
+ const ACTION_STATUS_COLOR = {
403
+ OPEN: chalk.yellow,
404
+ DONE: chalk.green,
405
+ CANCELLED: chalk.dim,
406
+ };
407
+
408
+ function actionKindLabel(kind) {
409
+ return kind === 'manual' || !kind ? 'manual' : `auto ${kind}`;
410
+ }
411
+
412
+ // Wording for the two verb families: `followups` (current) and the hidden
413
+ // legacy `actions` alias. Only labels differ; routes and payloads are shared.
414
+ const FOLLOWUP_NOUN = { one: 'follow-up', many: 'follow-ups', a: 'a follow-up' };
415
+ const LEGACY_ACTION_NOUN = { one: 'action item', many: 'action items', a: 'an action item', idLabel: 'action', glyph: 'action' };
416
+
417
+ function renderActionItems(items, { now = Date.now(), status = null, noun = LEGACY_ACTION_NOUN } = {}) {
418
+ const list = items || [];
419
+ if (list.length === 0) {
420
+ return [chalk.dim(` No ${status ? `${status} ` : ''}${noun.many}.`)];
421
+ }
422
+ const idW = idWidth(list.map((a) => `#${a.id}`), { min: 3 });
423
+ const streamW = idWidth(list.map((a) => a.stream_id), { min: 8 });
424
+ const kindW = Math.max(...list.map((a) => actionKindLabel(a.kind).length));
425
+ const lines = [''];
426
+ for (const a of list) {
427
+ const color = ACTION_STATUS_COLOR[a.status] || chalk.white;
428
+ const kind = actionKindLabel(a.kind);
429
+ const kindStr = a.kind === 'manual' ? chalk.dim(pad(kind, kindW)) : chalk.magenta(pad(kind, kindW));
430
+ const age = relativeAge(a.created_at, now).padStart(3);
431
+ const due = a.due_at ? ' ' + chalk.yellow(`due ${String(a.due_at).slice(0, 10)}`) : '';
432
+ lines.push(
433
+ ` ${chalk.cyan(pad(`#${a.id}`, idW))} ${color(pad(a.status, 9))} ${kindStr} `
434
+ + `${chalk.cyan(pad(truncate(a.stream_id, streamW), streamW))} ${chalk.dim(age)} ${truncate(a.title, 60)}${due}`
435
+ );
436
+ }
437
+ const counts = VALID_ACTION_STATUSES
438
+ .map((s) => [s, list.filter((a) => a.status === s).length])
439
+ .filter(([, n]) => n > 0)
440
+ .map(([s, n]) => `${n} ${s}`);
441
+ lines.push('', chalk.dim(` ${list.length} ${list.length === 1 ? noun.one : noun.many} (${counts.join(', ')})`));
442
+ return lines;
443
+ }
444
+
370
445
  // ── render: stats / completed ────────────────────────────────────────────
371
446
  // Fed by GET /roadmaps/:id/export (the same STRATEGIC_ROADMAP.json-shaped
372
447
  // doc the retired roadmap-cli used to read from disk). Adapted from the
@@ -397,7 +472,7 @@ function renderStats(doc) {
397
472
 
398
473
  const lines = [];
399
474
  lines.push('');
400
- lines.push(chalk.cyan.bold(' ROADMAP STATISTICS') + ' ' + chalk.dim(doc.updated || ''));
475
+ lines.push(chalk.cyan.bold(' ROADMAP STATISTICS') + (doc.updated ? ' ' + chalk.dim(`updated ${humanDate(doc.updated)}`) : ''));
401
476
  lines.push(' ' + '-'.repeat(50));
402
477
  lines.push(` ${chalk.green('✓ Completed')} ${completed.length} streams`);
403
478
  lines.push(` ${chalk.yellow('● In progress')} ${inProgress} streams`);
@@ -405,9 +480,21 @@ function renderStats(doc) {
405
480
  if (blocked) lines.push(` ${chalk.red('✗ Blocked')} ${blocked} streams`);
406
481
  if (decisions) lines.push(` ${chalk.magenta('? Needs decision')} ${decisions} streams`);
407
482
  lines.push('');
483
+ // Label the scope: the export's `streams` are OPEN streams only (completed
484
+ // ones are in `completed`), so this differs from the brief's all-streams
485
+ // phase line. Unlabelled, the two read as a contradiction.
408
486
  lines.push(
409
- ` Phase progress ${progressBar(donePhases, totalPhases || 1)} ${chalk.dim(`(${donePhases}/${totalPhases})`)}`
487
+ ` Phase progress (open streams) ${progressBar(donePhases, totalPhases || 1)} ${chalk.dim(`(${donePhases}/${totalPhases})`)}`
410
488
  );
489
+ // An all-streams line only when the completed rows carry phase counts;
490
+ // otherwise it would silently equal the open-streams number.
491
+ if (completed.length && completed.every((s) => Number.isFinite(s.phases_total))) {
492
+ const allTotal = totalPhases + completed.reduce((a, s) => a + (s.phases_total || 0), 0);
493
+ const allDone = donePhases + completed.reduce((a, s) => a + (s.phases_done || 0), 0);
494
+ lines.push(
495
+ ` Phase progress (all streams) ${progressBar(allDone, allTotal || 1)} ${chalk.dim(`(${allDone}/${allTotal})`)}`
496
+ );
497
+ }
411
498
  lines.push(` Tests shipped ${totalTests} ${chalk.dim('(completed streams)')}`);
412
499
  lines.push('');
413
500
  return lines;
@@ -469,6 +556,91 @@ async function resolveRoadmapId(client, explicit, orgLabel) {
469
556
  return roadmaps[0].id;
470
557
  }
471
558
 
559
+ const BRIEF_TOP = { def: 7, min: 1, max: 25 };
560
+ const BRIEF_DAYS = { def: 7, min: 1, max: 60 };
561
+ const WATCH_INTERVAL = { def: 30, min: 5 };
562
+
563
+ function intInRange(raw, { def, min, max }, flag) {
564
+ if (raw === undefined || raw === null) return def;
565
+ const n = Number(raw);
566
+ if (!Number.isInteger(n) || n < min || (max !== undefined && n > max)) {
567
+ throw new Error(`invalid ${flag} "${raw}" (expected a whole number ${max !== undefined ? `from ${min} to ${max}` : `of at least ${min}`})`);
568
+ }
569
+ return n;
570
+ }
571
+
572
+ /**
573
+ * GET the roadmap brief. A 404 means either the roadmap is missing (server
574
+ * says "roadmap not found") or the QuoxPlan server predates the brief
575
+ * endpoint (generic "not found"); both get a plain sentence, not a stack.
576
+ */
577
+ async function fetchBrief(client, roadmapId, { top, days }, orgLabel) {
578
+ const ep = resolveEndpoint('quoxplan.roadmap.brief.get', { roadmapId });
579
+ try {
580
+ return await client.get(ep.path, { service: ep.service, query: { top, days } });
581
+ } catch (err) {
582
+ if (err instanceof QuoxApiError && err.status === 404) {
583
+ const said = String(err.body?.error || err.code || err.message || '');
584
+ if (/roadmap not found/i.test(said)) {
585
+ throw new QuoxApiError(404, err.code, `Roadmap ${roadmapId} not found in org ${orgLabel}. It may exist in another org you belong to; switch with \`quox org switch <org>\`.`, err.detail, err.body);
586
+ }
587
+ throw new QuoxApiError(404, 'brief_unavailable', 'this QuoxPlan server does not have the brief endpoint yet, update QuoxPlan. Meanwhile `quox plan focus` and `quox plan stats` still work.', err.detail, err.body);
588
+ }
589
+ throw err;
590
+ }
591
+ }
592
+
593
+ /**
594
+ * --scope / --dev-server filters, sent only when given so an unfiltered call
595
+ * is byte-identical to before. scope = a division/department name (matched
596
+ * against a stream's assignee_scope); dev_server = a governed dev-server host
597
+ * id (streams tagged for that host, parents kept when a substream matches).
598
+ */
599
+ function filterQuery(options, { devServer = true } = {}) {
600
+ const q = {};
601
+ if (options.scope) q.scope = options.scope;
602
+ if (devServer && options.devServer) q.dev_server = options.devServer;
603
+ return q;
604
+ }
605
+
606
+ function filterLabel(options) {
607
+ const parts = [];
608
+ if (options.scope) parts.push(`scope: ${options.scope}`);
609
+ if (options.devServer) parts.push(`dev server: ${options.devServer}`);
610
+ return parts.length ? `, ${parts.join(', ')}` : '';
611
+ }
612
+
613
+ const SCOPE_HELP = 'only streams in this division or department scope (the name, as in the dashboard scope filter)';
614
+ const DEV_SERVER_HELP = 'only streams tagged for this governed dev server (host id)';
615
+
616
+ function parseActionId(raw, noun = LEGACY_ACTION_NOUN) {
617
+ const n = Number(raw);
618
+ if (!Number.isInteger(n) || n < 1) throw new Error(`${noun.idLabel || noun.one} id must be a positive whole number, got "${raw}"`);
619
+ return n;
620
+ }
621
+
622
+ function parseActionStatus(raw, flag) {
623
+ const status = String(raw || '').toUpperCase();
624
+ if (!VALID_ACTION_STATUSES.includes(status)) {
625
+ throw new Error(`invalid ${flag ? `${flag} ` : 'status '}"${raw}" (expected ${VALID_ACTION_STATUSES.join(', ')})`);
626
+ }
627
+ return status;
628
+ }
629
+
630
+ /**
631
+ * PATCH one follow-up's (server: action item's) status. Module-level (not a
632
+ * closure in the registrar) so cli/scripts/export-leaf-methods.mjs sees the
633
+ * PATCH and the chat policy classes `followup done` / `followup status` (and
634
+ * the hidden legacy `action done` / `action status`) as writes.
635
+ */
636
+ async function setActionStatus(client, program, actionIdArg, status, noun = LEGACY_ACTION_NOUN) {
637
+ const actionId = parseActionId(actionIdArg, noun);
638
+ const ep = resolveEndpoint('quoxplan.actionItem.update', { actionId });
639
+ const data = await client.patch(ep.path, { service: ep.service, body: { status, actor: 'quox-cli' } });
640
+ if (isJsonOutput(program)) return printJson(data);
641
+ printEvent(glyphLine('quoxplan', `${noun.glyph || noun.one} status`, { result: `#${data.id} ${data.stream_id} -> ${data.status}`, status: 'ok' }));
642
+ }
643
+
472
644
  function printJson(data) {
473
645
  console.log(JSON.stringify(data, null, 2));
474
646
  }
@@ -508,11 +680,14 @@ export function registerPlanCommands(program, ctx) {
508
680
 
509
681
  plan
510
682
  .command('roadmaps')
511
- .description('List roadmaps with status counts')
512
- .action(async () => {
683
+ .description('List roadmaps with status counts (archived roadmaps hidden unless --archived)')
684
+ .option('--archived', 'include archived roadmaps (marked "archived")')
685
+ .action(async (options = {}) => {
513
686
  auth.requireAuth();
514
687
  const ep = resolveEndpoint('quoxplan.roadmaps.list');
515
- const data = await client.get(ep.path, { service: ep.service });
688
+ const data = options.archived
689
+ ? await client.get(ep.path, { service: ep.service, query: { include_archived: '1' } })
690
+ : await client.get(ep.path, { service: ep.service });
516
691
  if (isJsonOutput(program)) return printJson(data);
517
692
  console.log(renderRoadmapsList(data.roadmaps || []).join('\n'));
518
693
  });
@@ -524,7 +699,7 @@ export function registerPlanCommands(program, ctx) {
524
699
  // standalone verb in its own right so nothing has to shell out to onboard.
525
700
  const roadmap = plan
526
701
  .command('roadmap')
527
- .description('Roadmap-level operations (create a roadmap)');
702
+ .description('Roadmap-level operations (create, archive, unarchive a roadmap)');
528
703
 
529
704
  roadmap
530
705
  .command('create')
@@ -563,6 +738,25 @@ export function registerPlanCommands(program, ctx) {
563
738
  printSuccess(`Created roadmap ${created.id || options.id} for org ${orgId}`);
564
739
  });
565
740
 
741
+ // QUOXPLAN-TERMINAL (gripe quoxai/quox#1586): archive hides a finished
742
+ // roadmap from `plan roadmaps` and `plan search` and freezes its streams
743
+ // (writes answer 409); its history stays readable by id. Not delete.
744
+ for (const [verb, endpointKey, done, help] of [
745
+ ['archive', 'quoxplan.roadmap.archive', 'Archived', 'Archive a roadmap: hide it from lists and search, refuse writes to its streams (history stays readable by id)'],
746
+ ['unarchive', 'quoxplan.roadmap.unarchive', 'Unarchived', 'Unarchive a roadmap: back in lists and search, writes allowed again'],
747
+ ]) {
748
+ roadmap
749
+ .command(`${verb} <roadmapId>`)
750
+ .description(help)
751
+ .action(async (roadmapId) => {
752
+ auth.requireAuth();
753
+ const ep = resolveEndpoint(endpointKey, { roadmapId });
754
+ const data = await client.post(ep.path, { service: ep.service, body: { actor: 'quox-cli' } });
755
+ if (isJsonOutput(program)) return printJson(data);
756
+ printSuccess(`${done} roadmap ${data.id || roadmapId}`);
757
+ });
758
+ }
759
+
566
760
  // ── search ───────────────────────────────────────────────────────────
567
761
 
568
762
  plan
@@ -590,6 +784,8 @@ export function registerPlanCommands(program, ctx) {
590
784
  .command('list [roadmap]')
591
785
  .description('Tier-grouped stream list (default roadmap: first by name)')
592
786
  .option('--lens <lens>', 'focus | tier | all', 'tier')
787
+ .option('--scope <scope>', SCOPE_HELP)
788
+ .option('--dev-server <hostId>', DEV_SERVER_HELP)
593
789
  .action(async (roadmapArg, options) => {
594
790
  auth.requireAuth();
595
791
  if (!VALID_LENSES.includes(options.lens)) {
@@ -597,33 +793,151 @@ export function registerPlanCommands(program, ctx) {
597
793
  }
598
794
  const roadmapId = await resolveRoadmapId(client, roadmapArg, describeActiveOrg(auth, program));
599
795
  const ep = resolveEndpoint('quoxplan.roadmap.streams.list', { roadmapId });
600
- const data = await client.get(ep.path, { service: ep.service, query: { lens: options.lens } });
796
+ const data = await client.get(ep.path, { service: ep.service, query: { lens: options.lens, ...filterQuery(options) } });
601
797
  if (isJsonOutput(program)) return printJson(data);
602
- console.log(`\n ${roadmapId} (lens: ${options.lens})`);
798
+ console.log(`\n ${roadmapId} (lens: ${options.lens}${filterLabel(options)})`);
603
799
  console.log(renderStreamList(data, { lens: options.lens }).join('\n'));
800
+ console.log(renderListFooter(data, options.lens).join('\n'));
604
801
  });
605
802
 
606
803
  // ── focus ────────────────────────────────────────────────────────────
607
804
 
608
805
  plan
609
806
  .command('focus [roadmap]')
610
- .description('The focus lens: what matters now (P0, blocked, decision needed, in-flight, stale)')
611
- .action(async (roadmapArg) => {
807
+ .description('What matters now: everything blocked or waiting on a decision, the top 12 in flight, and a stale count (--all for the full focus list)')
808
+ .option('--all', 'print the full focus list (every P0, blocked, decision, in-flight and stale stream)')
809
+ .option('--scope <scope>', SCOPE_HELP)
810
+ .option('--dev-server <hostId>', DEV_SERVER_HELP)
811
+ .action(async (roadmapArg, options) => {
612
812
  auth.requireAuth();
613
813
  const roadmapId = await resolveRoadmapId(client, roadmapArg, describeActiveOrg(auth, program));
614
814
  const ep = resolveEndpoint('quoxplan.roadmap.streams.list', { roadmapId });
615
- const data = await client.get(ep.path, { service: ep.service, query: { lens: 'focus' } });
815
+ const data = await client.get(ep.path, { service: ep.service, query: { lens: 'focus', ...filterQuery(options) } });
616
816
  if (isJsonOutput(program)) return printJson(data);
617
- console.log(`\n ${roadmapId} (focus)`);
618
- console.log(renderStreamList(data, { lens: 'focus' }).join('\n'));
817
+ if (options.all) {
818
+ console.log(`\n ${roadmapId} (focus, all${filterLabel(options)})`);
819
+ console.log(renderStreamList(data, { lens: 'focus' }).join('\n'));
820
+ console.log(renderListFooter(data, 'focus').join('\n'));
821
+ return;
822
+ }
823
+ console.log('');
824
+ if (filterLabel(options)) console.log(chalk.dim(` filtered by ${filterLabel(options).slice(2)}`));
825
+ console.log(renderFocusCapped(data, { roadmapId, width: termWidth() }).join('\n'));
826
+ });
827
+
828
+ // ── brief ────────────────────────────────────────────────────────────
829
+ // One screen: what to work on, what needs you, what moved, what is stale.
830
+ // Fed by the server's brief computation so CLI and chat give one answer.
831
+
832
+ const runBrief = async (roadmapArg, options = {}) => {
833
+ auth.requireAuth();
834
+ const top = intInRange(options.top, BRIEF_TOP, '--top');
835
+ const days = intInRange(options.days, BRIEF_DAYS, '--days');
836
+ const orgLabel = describeActiveOrg(auth, program);
837
+ const roadmapId = await resolveRoadmapId(client, roadmapArg, orgLabel);
838
+ const data = await fetchBrief(client, roadmapId, { top, days }, orgLabel);
839
+ if (isJsonOutput(program)) return printJson(data);
840
+ console.log('');
841
+ console.log(renderBrief(data, { width: termWidth(), top, days }).join('\n'));
842
+ };
843
+
844
+ plan
845
+ .command('brief [roadmap]')
846
+ .description('One-screen roadmap brief: top priorities, what needs you, what to unblock, what moved, what is stale')
847
+ .option('--top <n>', `how many top priorities to show (${BRIEF_TOP.min} to ${BRIEF_TOP.max}, default ${BRIEF_TOP.def})`)
848
+ .option('--days <d>', `window for "moved" in days (${BRIEF_DAYS.min} to ${BRIEF_DAYS.max}, default ${BRIEF_DAYS.def})`)
849
+ .action(runBrief);
850
+
851
+ // Bare `quox plan` prints the brief for the default roadmap.
852
+ plan
853
+ .allowExcessArguments(true)
854
+ .action(async (_opts, cmd) => {
855
+ const extra = (cmd && cmd.args) || [];
856
+ if (extra.length) {
857
+ throw new Error(`unknown command 'plan ${extra[0]}'. See \`quox plan --help\`.`);
858
+ }
859
+ await runBrief(undefined, {});
860
+ });
861
+
862
+ // ── watch ────────────────────────────────────────────────────────────
863
+
864
+ plan
865
+ .command('watch [roadmap]')
866
+ .description('Live board: redraws the brief every interval and ticks off changes since you started watching (Ctrl-C exits)')
867
+ .option('--interval <sec>', `seconds between refreshes (at least ${WATCH_INTERVAL.min}, default ${WATCH_INTERVAL.def})`)
868
+ .option('--top <n>', `how many top priorities to show (default ${BRIEF_TOP.def})`)
869
+ .option('--days <d>', `window for "moved" in days (default ${BRIEF_DAYS.def})`)
870
+ .action(async (roadmapArg, options) => {
871
+ auth.requireAuth();
872
+ const interval = intInRange(options.interval, WATCH_INTERVAL, '--interval');
873
+ const top = intInRange(options.top, BRIEF_TOP, '--top');
874
+ const days = intInRange(options.days, BRIEF_DAYS, '--days');
875
+ const orgLabel = describeActiveOrg(auth, program);
876
+ const roadmapId = await resolveRoadmapId(client, roadmapArg, orgLabel);
877
+ const first = await fetchBrief(client, roadmapId, { top, days }, orgLabel);
878
+
879
+ if (isJsonOutput(program) || !process.stdout.isTTY) {
880
+ if (isJsonOutput(program)) printJson(first);
881
+ else {
882
+ console.log('');
883
+ console.log(renderBrief(first, { width: termWidth(), top, days }).join('\n'));
884
+ console.log(chalk.dim('\n stdout is not a terminal, so this printed one brief instead of a live board.'));
885
+ }
886
+ return;
887
+ }
888
+
889
+ const since = new Date().toISOString();
890
+ const seen = [];
891
+ let prev = first;
892
+ let lastError = null;
893
+ let stopped = false;
894
+ let wake = null;
895
+ const draw = () => {
896
+ const width = termWidth();
897
+ const out = [
898
+ ...renderBrief(prev, { width, top, days }),
899
+ ...renderTicker(seen, { since, width }),
900
+ ];
901
+ if (lastError) out.push(chalk.yellow(` last refresh failed: ${lastError}. Retrying every ${interval}s.`));
902
+ process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n');
903
+ };
904
+ const stop = () => {
905
+ stopped = true;
906
+ if (wake) wake();
907
+ };
908
+ process.once('SIGINT', stop);
909
+ try {
910
+ draw();
911
+ while (!stopped) {
912
+ await new Promise((resolve) => {
913
+ const t = setTimeout(resolve, interval * 1000);
914
+ wake = () => { clearTimeout(t); resolve(); };
915
+ });
916
+ if (stopped) break;
917
+ try {
918
+ const next = await fetchBrief(client, roadmapId, { top, days }, orgLabel);
919
+ const at = new Date().toISOString();
920
+ for (const change of diffBriefs(prev, next)) seen.push({ change, at });
921
+ prev = next;
922
+ lastError = null;
923
+ } catch (err) {
924
+ lastError = err.message;
925
+ }
926
+ draw();
927
+ }
928
+ } finally {
929
+ process.removeListener('SIGINT', stop);
930
+ }
931
+ console.log(chalk.dim(`\n stopped watching ${roadmapId}. ${seen.length} change${seen.length === 1 ? '' : 's'} seen.`));
619
932
  });
620
933
 
621
934
  // ── show ─────────────────────────────────────────────────────────────
622
935
 
623
936
  plan
624
937
  .command('show <streamId>')
625
- .description('Full stream detail: header, phases checklist, plan, notes timeline, deps')
626
- .action(async (streamId) => {
938
+ .description('Stream detail: header, phases checklist, deps, plan (first 40 lines) and the newest 5 notes (--full for everything)')
939
+ .option('--full', 'print the whole plan and every note')
940
+ .action(async (streamId, options) => {
627
941
  auth.requireAuth();
628
942
  const ep = resolveEndpoint('quoxplan.stream.get', { id: streamId });
629
943
  let detail;
@@ -636,7 +950,7 @@ export function registerPlanCommands(program, ctx) {
636
950
  throw err;
637
951
  }
638
952
  if (isJsonOutput(program)) return printJson(detail);
639
- console.log(renderStreamDetail(detail).join('\n'));
953
+ console.log(renderStreamDetail(detail, { full: !!options.full }).join('\n'));
640
954
  });
641
955
 
642
956
  // ── timeline ─────────────────────────────────────────────────────────
@@ -645,15 +959,128 @@ export function registerPlanCommands(program, ctx) {
645
959
  .command('timeline [roadmap]')
646
960
  .description('ASCII Gantt: dependency-sequenced schedule, critical path bold, blocked edges marked')
647
961
  .option('--weeks <n>', 'force the number of week columns shown', (v) => parseInt(v, 10))
962
+ // No --dev-server here: the timeline route reads only ?scope (roadmaps.js).
963
+ .option('--scope <scope>', SCOPE_HELP)
648
964
  .action(async (roadmapArg, options) => {
649
965
  auth.requireAuth();
650
966
  const roadmapId = await resolveRoadmapId(client, roadmapArg, describeActiveOrg(auth, program));
651
967
  const ep = resolveEndpoint('quoxplan.roadmap.timeline.get', { roadmapId });
652
- const data = await client.get(ep.path, { service: ep.service });
968
+ const query = filterQuery(options, { devServer: false });
969
+ const data = await client.get(ep.path, Object.keys(query).length ? { service: ep.service, query } : { service: ep.service });
653
970
  if (isJsonOutput(program)) return printJson(data);
654
- console.log(renderTimeline(data.timeline, { weeks: options.weeks, title: roadmapId }).join('\n'));
971
+ const title = options.scope ? `${roadmapId} (scope: ${options.scope})` : roadmapId;
972
+ console.log(renderTimeline(data.timeline, { weeks: options.weeks, title }).join('\n'));
655
973
  });
656
974
 
975
+ // ── followups / followup (legacy hidden: actions / action) ──────────
976
+ // The dashboard's follow-ups panel, from the terminal. `followups` lists
977
+ // (roadmap-wide, or one stream with --stream); `followup add|done|status`
978
+ // writes. `followup` is a pure group with no action of its own, so every
979
+ // leaf classes cleanly as read or write in the chat CLI policy.
980
+ // Owner 2026-11-08: renamed from `actions` / `action` because those
981
+ // collided with the Action Tracker's `quox actions`. The old names stay
982
+ // registered as hidden aliases (same handlers, old wording) so existing
983
+ // scripts keep working; `quox plan --help` lists only the follow-up verbs.
984
+
985
+ const followupFamilies = [
986
+ { list: 'followups', group: 'followup', idArg: 'followupId', noun: FOLLOWUP_NOUN, hidden: false },
987
+ { list: 'actions', group: 'action', idArg: 'actionId', noun: LEGACY_ACTION_NOUN, hidden: true },
988
+ ];
989
+
990
+ for (const fam of followupFamilies) {
991
+ const { noun } = fam;
992
+ // Hidden on the legacy group AND its subcommands, so the leaf tree flags every legacy leaf hidden.
993
+ const cmdOpts = fam.hidden ? { hidden: true } : {};
994
+
995
+ plan
996
+ .command(`${fam.list} [roadmap]`, cmdOpts)
997
+ .description(`List ${noun.many}: roadmap-wide (default roadmap: first by name), or one stream with --stream`)
998
+ .option('--stream <streamId>', `list one stream's ${noun.many} instead of the whole roadmap`)
999
+ .option('--status <status>', `only items in this status (${VALID_ACTION_STATUSES.join(', ')})`)
1000
+ .action(async (roadmapArg, options) => {
1001
+ auth.requireAuth();
1002
+ const status = options.status ? parseActionStatus(options.status, '--status') : null;
1003
+ if (roadmapArg && options.stream) {
1004
+ throw new Error('give either a roadmap or --stream <streamId>, not both');
1005
+ }
1006
+ const orgLabel = describeActiveOrg(auth, program);
1007
+ let data;
1008
+ let items;
1009
+ if (options.stream) {
1010
+ const ep = resolveEndpoint('quoxplan.stream.actionItems.list', { id: options.stream });
1011
+ try {
1012
+ data = await client.get(ep.path, { service: ep.service });
1013
+ } catch (err) {
1014
+ if (err instanceof QuoxApiError && err.status === 404) {
1015
+ throw new QuoxApiError(404, err.code, `Stream ${options.stream} not found in org ${orgLabel}. It may exist in another org you belong to; switch with \`quox org switch <org>\`.`, err.detail, err.body);
1016
+ }
1017
+ throw err;
1018
+ }
1019
+ // The per-stream route has no status filter, so filter here.
1020
+ items = (data.action_items || []).filter((a) => !status || a.status === status);
1021
+ if (status) data = { ...data, status, action_items: items };
1022
+ } else {
1023
+ const roadmapId = await resolveRoadmapId(client, roadmapArg, orgLabel);
1024
+ const ep = resolveEndpoint('quoxplan.roadmap.actionItems.list', { roadmapId });
1025
+ try {
1026
+ data = await client.get(ep.path, status ? { service: ep.service, query: { status } } : { service: ep.service });
1027
+ } catch (err) {
1028
+ if (err instanceof QuoxApiError && err.status === 404) {
1029
+ throw new QuoxApiError(404, err.code, `Roadmap ${roadmapId} not found in org ${orgLabel}, or this QuoxPlan server has no ${noun.many} yet. Switch org with \`quox org switch <org>\`.`, err.detail, err.body);
1030
+ }
1031
+ throw err;
1032
+ }
1033
+ items = data.action_items || [];
1034
+ }
1035
+ if (isJsonOutput(program)) return printJson(data);
1036
+ const where = options.stream || data.roadmap_id || roadmapArg;
1037
+ console.log(`\n ${where} (${noun.many}${status ? `, ${status}` : ''})`);
1038
+ console.log(renderActionItems(items, { status, noun }).join('\n'));
1039
+ });
1040
+
1041
+ const group = plan
1042
+ .command(fam.group, cmdOpts)
1043
+ .description(`${noun.one[0].toUpperCase()}${noun.one.slice(1)} writes: add a manual item, mark one done, or set its status`);
1044
+
1045
+ group
1046
+ .command('add <streamId> <title...>', cmdOpts)
1047
+ .description(`Add a manual ${noun.one} to a stream`)
1048
+ .option('--due <date>', 'due date (ISO 8601, e.g. 2026-12-15)')
1049
+ .action(async (streamId, titleParts, options) => {
1050
+ auth.requireAuth();
1051
+ const title = titleParts.join(' ').trim();
1052
+ if (!title) throw new Error(`${noun.one} title is required`);
1053
+ const body = { title, actor: 'quox-cli' };
1054
+ if (options.due !== undefined) {
1055
+ if (!/^\d{4}-\d{2}-\d{2}/.test(options.due) || !Number.isFinite(Date.parse(options.due))) {
1056
+ throw new Error(`invalid --due "${options.due}" (expected an ISO 8601 date, e.g. 2026-12-15)`);
1057
+ }
1058
+ body.due_at = options.due;
1059
+ }
1060
+ const ep = resolveEndpoint('quoxplan.stream.actionItems.create', { id: streamId });
1061
+ const data = await client.post(ep.path, { service: ep.service, body });
1062
+ if (isJsonOutput(program)) return printJson(data);
1063
+ printEvent(glyphLine('quoxplan', `${noun.glyph || noun.one} added`, { result: `${streamId} #${data.id} ${title}`, status: 'ok' }));
1064
+ });
1065
+
1066
+ group
1067
+ .command(`done <${fam.idArg}>`, cmdOpts)
1068
+ .description(`Mark ${noun.a} DONE`)
1069
+ .action(async (actionIdArg) => {
1070
+ auth.requireAuth();
1071
+ await setActionStatus(client, program, actionIdArg, 'DONE', noun);
1072
+ });
1073
+
1074
+ group
1075
+ .command(`status <${fam.idArg}> <status>`, cmdOpts)
1076
+ .description(`Set ${noun.a}'s status (${VALID_ACTION_STATUSES.join(', ')})`)
1077
+ .action(async (actionIdArg, statusArg) => {
1078
+ auth.requireAuth();
1079
+ const status = parseActionStatus(statusArg);
1080
+ await setActionStatus(client, program, actionIdArg, status, noun);
1081
+ });
1082
+ }
1083
+
657
1084
  // ── status ───────────────────────────────────────────────────────────
658
1085
 
659
1086
  plan
@@ -853,7 +1280,7 @@ export function registerPlanCommands(program, ctx) {
853
1280
  plan
854
1281
  .command('add <json>')
855
1282
  .description('Create a new stream from a JSON object (id, name required)')
856
- .option('--roadmap <roadmap>', 'roadmap id (default: first roadmap)')
1283
+ .option('--roadmap <roadmap>', 'roadmap id (default: roadmap_id in the JSON, else the first roadmap)')
857
1284
  .action(async (json, options) => {
858
1285
  auth.requireAuth();
859
1286
  let body;
@@ -863,7 +1290,15 @@ export function registerPlanCommands(program, ctx) {
863
1290
  throw new Error(`invalid JSON: ${err.message}`);
864
1291
  }
865
1292
  body.actor = body.actor || 'quox-cli';
866
- const roadmapId = await resolveRoadmapId(client, options.roadmap, describeActiveOrg(auth, program));
1293
+ // The roadmap comes from the URL, never the body. A roadmap_id in the JSON used to be silently ignored and the
1294
+ // stream landed in the FIRST roadmap (2026-11-09: a fixture seed filled the wrong roadmap). Honour it, and refuse
1295
+ // a disagreement with --roadmap rather than guess.
1296
+ const jsonRoadmap = typeof body.roadmap_id === 'string' && body.roadmap_id ? body.roadmap_id : null;
1297
+ delete body.roadmap_id;
1298
+ if (jsonRoadmap && options.roadmap && jsonRoadmap !== options.roadmap) {
1299
+ throw new Error(`the JSON says roadmap_id "${jsonRoadmap}" but --roadmap "${options.roadmap}": pass one, or make them agree`);
1300
+ }
1301
+ const roadmapId = await resolveRoadmapId(client, options.roadmap || jsonRoadmap, describeActiveOrg(auth, program));
867
1302
  const ep = resolveEndpoint('quoxplan.roadmap.streams.create', { roadmapId });
868
1303
  const data = await client.post(ep.path, { service: ep.service, body });
869
1304
  if (isJsonOutput(program)) return printJson(data);