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
@@ -0,0 +1,614 @@
1
+ /**
2
+ * Pure renderers for the QuoxPlan one-screen views (QUOXPLAN-TERMINAL):
3
+ * `quox plan brief` / bare `quox plan`, the capped `quox plan focus`, and the
4
+ * change ticker for `quox plan watch`. No network, no process globals beyond
5
+ * a width fallback, so every function here is unit-testable on a fixture.
6
+ *
7
+ * Input shape for renderBrief/diffBriefs is the server's brief payload
8
+ * (GET /api/v1/roadmaps/:id/brief, pinned contract 2026-11-08).
9
+ *
10
+ * Layout rule (same as plan.js): pad and truncate the PLAIN text first,
11
+ * colour it last. Rows are built as segment lists [text, colourFn] and fitted
12
+ * to the terminal width by fit(), which cuts plain text and only then applies
13
+ * chalk, so escape bytes never count toward column width.
14
+ *
15
+ * Colour rule (owner): content is never dim. Dim is for chrome only
16
+ * (legends, separators, hints). Ids cyan, decisions magenta, blocked red,
17
+ * phase bars green on dim.
18
+ */
19
+
20
+ import chalk from 'chalk';
21
+
22
+ // ── glyphs (single source for plan.js too) ───────────────────────────────
23
+
24
+ export const STATUS_GLYPH = {
25
+ NOT_STARTED: { glyph: '◇', color: chalk.dim, word: 'not started' },
26
+ IN_PROGRESS: { glyph: '⬗', color: chalk.yellow, word: 'in progress' },
27
+ BLOCKED: { glyph: '◆', color: chalk.red, word: 'blocked' },
28
+ DECISION_NEEDED: { glyph: '◈', color: chalk.magenta, word: 'decision' },
29
+ // ✔, not ◆: COMPLETE used to share ◆ with BLOCKED, so the only difference
30
+ // between "done" and "stuck" was red vs dim. Distinct shape now.
31
+ COMPLETE: { glyph: '✔', color: chalk.dim, word: 'complete' },
32
+ };
33
+
34
+ export function statusGlyph(status) {
35
+ return STATUS_GLYPH[status] || { glyph: '◇', color: chalk.dim, word: String(status || 'unknown').toLowerCase() };
36
+ }
37
+
38
+ export const STATUS_LEGEND = '◇ not started ⬗ in progress ◈ decision needed ◆ blocked ✔ complete';
39
+
40
+ // ── text helpers ─────────────────────────────────────────────────────────
41
+
42
+ export function pad(value, n) {
43
+ const s = String(value ?? '');
44
+ return s.length >= n ? s : s + ' '.repeat(n - s.length);
45
+ }
46
+
47
+ export function padStart(value, n) {
48
+ const s = String(value ?? '');
49
+ return s.length >= n ? s : ' '.repeat(n - s.length) + s;
50
+ }
51
+
52
+ export function truncate(value, n) {
53
+ const s = String(value ?? '');
54
+ if (n <= 0) return '';
55
+ if (s.length <= n) return s;
56
+ return s.slice(0, Math.max(0, n - 1)) + '…';
57
+ }
58
+
59
+ const plain = (s) => s;
60
+
61
+ /**
62
+ * Fit a row of [text, colourFn] segments into `width` visible columns.
63
+ * Truncates the plain text (adding … at the cut) and colours afterwards.
64
+ */
65
+ export function fit(segments, width) {
66
+ let used = 0;
67
+ let out = '';
68
+ for (const [rawText, color = plain] of segments) {
69
+ const text = String(rawText ?? '');
70
+ if (!text) continue;
71
+ const room = width - used;
72
+ if (room <= 0) break;
73
+ if (text.length <= room) {
74
+ out += color(text);
75
+ used += text.length;
76
+ } else {
77
+ out += color(truncate(text, room));
78
+ used = width;
79
+ break;
80
+ }
81
+ }
82
+ return out.replace(/ +$/, '');
83
+ }
84
+
85
+ /** Terminal width, fallback 100 when not a TTY (pipes, CI, tests). */
86
+ export function termWidth(stream = process.stdout) {
87
+ const c = stream && stream.columns;
88
+ return Number.isInteger(c) && c > 20 ? c : 100;
89
+ }
90
+
91
+ /**
92
+ * Short relative age: now, 5m, 2h, 3d, 5w, 2y. Anything under 14 days is
93
+ * shown in days so "last week" reads as 9d, not 1w.
94
+ */
95
+ export function relativeAge(iso, now = Date.now()) {
96
+ if (!iso) return '?';
97
+ const t = typeof iso === 'number' ? iso : Date.parse(iso);
98
+ if (!Number.isFinite(t)) return '?';
99
+ const s = Math.floor((now - t) / 1000);
100
+ if (s < 60) return 'now';
101
+ const m = Math.floor(s / 60);
102
+ if (m < 60) return `${m}m`;
103
+ const h = Math.floor(m / 60);
104
+ if (h < 24) return `${h}h`;
105
+ const d = Math.floor(h / 24);
106
+ if (d < 14) return `${d}d`;
107
+ const w = Math.floor(d / 7);
108
+ if (w < 52) return `${w}w`;
109
+ return `${Math.floor(d / 365)}y`;
110
+ }
111
+
112
+ /** "Sun 8 Nov 2026, 16:35 UTC". Returns the input unchanged if unparseable. */
113
+ export function humanDate(iso) {
114
+ const t = Date.parse(iso);
115
+ if (!Number.isFinite(t)) return String(iso ?? '');
116
+ const d = new Date(t);
117
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
118
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
119
+ const hh = String(d.getUTCHours()).padStart(2, '0');
120
+ const mm = String(d.getUTCMinutes()).padStart(2, '0');
121
+ return `${days[d.getUTCDay()]} ${d.getUTCDate()} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}, ${hh}:${mm} UTC`;
122
+ }
123
+
124
+ function hhmm(iso) {
125
+ const t = Date.parse(iso);
126
+ if (!Number.isFinite(t)) return '--:--';
127
+ const d = new Date(t);
128
+ return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
129
+ }
130
+
131
+ /** Id column width from the data, so ids longer than 18 chars never push columns. */
132
+ export function idWidth(items, { min = 8, max = 32 } = {}) {
133
+ let w = min;
134
+ for (const it of items || []) {
135
+ const id = it && (typeof it === 'string' ? it : it.id);
136
+ if (id) w = Math.max(w, String(id).length);
137
+ }
138
+ return Math.min(w, max);
139
+ }
140
+
141
+ /** Phase mini-bar: 5 cells, green filled on dim empty, e.g. ■■■□□. */
142
+ export function miniBar(done, total, cells = 5) {
143
+ if (!total || total <= 0) return [['·'.repeat(cells), chalk.dim]];
144
+ const filled = Math.max(0, Math.min(cells, Math.round((done / total) * cells)));
145
+ return [['■'.repeat(filled), chalk.green], ['□'.repeat(cells - filled), chalk.dim]];
146
+ }
147
+
148
+ function wideBar(done, total, cells) {
149
+ if (!total || total <= 0) return [['░'.repeat(cells), chalk.dim]];
150
+ const filled = Math.max(0, Math.min(cells, Math.round((done / total) * cells)));
151
+ return [['█'.repeat(filled), chalk.green], ['░'.repeat(cells - filled), chalk.dim]];
152
+ }
153
+
154
+ const BAR_ORDER = [
155
+ ['COMPLETE', '█', chalk.green],
156
+ ['IN_PROGRESS', '█', chalk.yellow],
157
+ ['DECISION_NEEDED', '█', chalk.magenta],
158
+ ['BLOCKED', '█', chalk.red],
159
+ ['NOT_STARTED', '░', chalk.dim],
160
+ ];
161
+
162
+ /** Stacked status bar (largest-remainder allocation so cells always sum to `cells`). */
163
+ function statusBar(byStatus, cells) {
164
+ const total = BAR_ORDER.reduce((n, [k]) => n + (byStatus[k] || 0), 0);
165
+ if (!total) return [['░'.repeat(cells), chalk.dim]];
166
+ const raw = BAR_ORDER.map(([k]) => ((byStatus[k] || 0) / total) * cells);
167
+ const alloc = raw.map(Math.floor);
168
+ let left = cells - alloc.reduce((a, b) => a + b, 0);
169
+ const order = raw.map((r, i) => [r - Math.floor(r), i]).sort((a, b) => b[0] - a[0]);
170
+ for (let k = 0; left > 0 && k < order.length; k++, left--) alloc[order[k][1]]++;
171
+ return BAR_ORDER.map(([, ch, color], i) => [ch.repeat(alloc[i]), color]);
172
+ }
173
+
174
+ // ── brief ────────────────────────────────────────────────────────────────
175
+
176
+ export const BRIEF_CAPS = { needsYou: 5, unblockers: 3, recent: 4, staleOldest: 5 };
177
+
178
+ const LEAD = 5; // " 12 " rank column; non-ranked rows indent the same so ids align
179
+
180
+ function glyphSeg(status) {
181
+ const g = statusGlyph(status);
182
+ return [g.glyph, g.color.bold];
183
+ }
184
+
185
+ /**
186
+ * Render the brief payload as one screen (<= 40 lines at the defaults).
187
+ * opts: { width, now, top, days }
188
+ */
189
+ export function renderBrief(brief, opts = {}) {
190
+ const width = opts.width || 100;
191
+ const now = opts.now ?? Date.now();
192
+ const days = opts.days || 7;
193
+ const b = brief || {};
194
+ const totals = b.totals || {};
195
+ const byStatus = totals.by_status || {};
196
+ const top = b.top || [];
197
+ const needs = b.needs_you || [];
198
+ const recent = b.recent || [];
199
+ const unblockers = b.unblockers || [];
200
+ const stale = b.stale || { count: 0, oldest: [] };
201
+
202
+ const needsShown = needs.slice(0, BRIEF_CAPS.needsYou);
203
+ const unblockShown = unblockers.slice(0, BRIEF_CAPS.unblockers);
204
+ const recentShown = recent.slice(0, BRIEF_CAPS.recent);
205
+
206
+ const idW = idWidth([...top, ...needsShown, ...unblockShown, ...recentShown]);
207
+ const countW = Math.max(3, ...top.map((s) => `${s.phases_done ?? 0}/${s.phases_total ?? 0}`.length));
208
+ const depsLen = Math.max(0, ...top.map((s) => depsText(s.unmet_deps).length));
209
+ const depsReserve = depsLen ? depsLen + 2 : 0;
210
+ // lead + glyph + space + id + 2 + [name] + 2 + bar(5) + 1 + count + 2 + age(3)
211
+ const fixed = LEAD + 2 + idW + 2 + 2 + 5 + 1 + countW + 2 + 3;
212
+ const nameW = Math.max(12, Math.min(48, width - fixed - depsReserve));
213
+
214
+ const lines = [];
215
+
216
+ // header card
217
+ const topN = opts.top || top.length;
218
+ const right = `as of ${hhmm(b.generated_at)} UTC · top ${topN} · moved = last ${days}d`;
219
+ const left = ` QUOXPLAN ${b.roadmap_id || ''}`;
220
+ const gap = Math.max(2, width - left.length - right.length);
221
+ lines.push(fit([[' QUOXPLAN', chalk.cyan.bold], [' '], [b.roadmap_id || '', chalk.bold], [' '.repeat(gap)], [right, chalk.dim]], width));
222
+
223
+ const barW = Math.max(10, Math.min(30, width - 30));
224
+ lines.push(fit([[' streams ', chalk.dim], ...statusBar(byStatus, barW), [' '], [String(totals.streams ?? 0), chalk.bold], [' streams']], width));
225
+ const legend = [];
226
+ for (const k of ['COMPLETE', 'IN_PROGRESS', 'BLOCKED', 'DECISION_NEEDED', 'NOT_STARTED']) {
227
+ const g = statusGlyph(k);
228
+ legend.push([g.glyph, g.color.bold], [` ${byStatus[k] || 0} ${g.word}`], [' ']);
229
+ }
230
+ lines.push(fit([[' '], ...legend], width));
231
+ const pd = totals.phases_done || 0;
232
+ const pt = totals.phases_total || 0;
233
+ const pct = pt ? Math.round((pd / pt) * 100) : 0;
234
+ lines.push(fit([[' phases ', chalk.dim], ...wideBar(pd, pt, barW), [' '], [`${pct}%`, chalk.bold], [` ${pd} of ${pt} done`], [' (all streams)', chalk.dim]], width));
235
+ lines.push('');
236
+
237
+ // top priorities
238
+ lines.push(fit([[' TOP PRIORITIES', chalk.cyan.bold], [' not complete, by priority', chalk.dim]], width));
239
+ if (top.length === 0) lines.push(fit([[' Nothing open. Every top-level stream is complete.', chalk.green]], width));
240
+ top.forEach((s, i) => {
241
+ const count = `${s.phases_done ?? 0}/${s.phases_total ?? 0}`;
242
+ const deps = depsText(s.unmet_deps);
243
+ lines.push(fit([
244
+ [padStart(i + 1, 4) + ' ', chalk.bold],
245
+ glyphSeg(s.status), [' '],
246
+ [pad(truncate(s.id, idW), idW), chalk.cyan], [' '],
247
+ [pad(truncate(s.name, nameW), nameW)], [' '],
248
+ ...miniBar(s.phases_done, s.phases_total), [' '],
249
+ [padStart(count, countW)], [' '],
250
+ [padStart(relativeAge(s.last_activity_at, now), 3)],
251
+ ...(deps ? [[' '], [deps, chalk.yellow]] : []),
252
+ ], width));
253
+ });
254
+ lines.push('');
255
+
256
+ // needs you
257
+ const needsTotal = b.needs_you_total ?? needs.length;
258
+ if (needsTotal === 0) {
259
+ lines.push(fit([[' NEEDS YOU', chalk.cyan.bold], [' nothing blocked or waiting on a decision', chalk.green]], width));
260
+ } else {
261
+ lines.push(fit([
262
+ [' NEEDS YOU', chalk.cyan.bold], [' '], [String(needsTotal), chalk.red.bold],
263
+ [needsTotal > needsShown.length ? ` showing ${needsShown.length}, all: quox plan focus` : ' blocked or waiting on a decision', chalk.dim],
264
+ ], width));
265
+ const nDeps = Math.max(0, ...needsShown.map((s) => depsText(s.unmet_deps).length));
266
+ const nNameW = Math.max(12, Math.min(48, width - (LEAD + 2 + idW + 2 + 2 + 8 + 2 + 3 + 2 + 3) - (nDeps ? nDeps + 2 : 0)));
267
+ for (const s of needsShown) {
268
+ const isDecision = s.status === 'DECISION_NEEDED';
269
+ const deps = depsText(s.unmet_deps);
270
+ lines.push(fit([
271
+ [' '.repeat(LEAD)], glyphSeg(s.status), [' '],
272
+ [pad(truncate(s.id, idW), idW), chalk.cyan], [' '],
273
+ [pad(truncate(s.name, nNameW), nNameW)], [' '],
274
+ [pad(isDecision ? 'DECISION' : 'BLOCKED', 8), isDecision ? chalk.magenta.bold : chalk.red.bold], [' '],
275
+ [pad(`T${s.tier ?? '?'}`, 3)], [' '],
276
+ [padStart(relativeAge(s.last_activity_at, now), 3)],
277
+ ...(deps ? [[' '], [deps, chalk.yellow]] : []),
278
+ ], width));
279
+ const note = s.latest_note;
280
+ lines.push(fit(note && note.body
281
+ ? [[' '.repeat(LEAD + 2) + '└ ', chalk.dim], [`${relativeAge(note.created_at, now)} `, chalk.dim], [oneLine(note.body)]]
282
+ : [[' '.repeat(LEAD + 2) + '└ no notes yet', chalk.dim]], width));
283
+ }
284
+ }
285
+
286
+ // unblock these first
287
+ if (unblockShown.length) {
288
+ lines.push('');
289
+ lines.push(fit([[' UNBLOCK THESE FIRST', chalk.cyan.bold], [' finishing one frees the streams waiting on it', chalk.dim]], width));
290
+ const freesOf = (s) => (Number.isFinite(s.unblocks_total) ? s.unblocks_total : (s.unblocks || []).length);
291
+ const uDeps = Math.max(0, ...unblockShown.map((s) => depsText(s.unmet_deps).length));
292
+ const freesW = Math.max(...unblockShown.map((s) => `frees ${freesOf(s)}: `.length));
293
+ // leave at least 20 columns for the freed ids and room for "after X"
294
+ const uNameW = Math.max(12, Math.min(40, width - (LEAD + 2 + idW + 2 + 2) - freesW - 20 - (uDeps ? uDeps + 2 : 0)));
295
+ for (const s of unblockShown) {
296
+ // unblocks_chain (server, when present) names every stream the chain frees,
297
+ // so the count and the names agree; older servers send only the direct list.
298
+ const list = Array.isArray(s.unblocks_chain) && s.unblocks_chain.length ? s.unblocks_chain : (s.unblocks || []);
299
+ // unblocks_total = every open stream freed through the downstream chain
300
+ // (server 984a930); older servers only send the direct list.
301
+ const frees = freesOf(s);
302
+ const deps = depsText(s.unmet_deps);
303
+ const head = LEAD + 2 + idW + 2 + uNameW + 2 + `frees ${frees}: `.length;
304
+ const room = Math.max(8, width - head - (deps ? deps.length + 2 : 0));
305
+ lines.push(fit([
306
+ [' '.repeat(LEAD)], glyphSeg(s.status), [' '],
307
+ [pad(truncate(s.id, idW), idW), chalk.cyan], [' '],
308
+ [pad(truncate(s.name, uNameW), uNameW)], [' '],
309
+ ['frees '], [String(frees), chalk.bold], [': '], [truncate(list.join(', '), room), chalk.cyan],
310
+ ...(deps ? [[' '], [deps, chalk.yellow]] : []),
311
+ ], width));
312
+ }
313
+ }
314
+ lines.push('');
315
+
316
+ // moved this week
317
+ const recentTotal = b.recent_total ?? recent.length;
318
+ const movedTitle = days === 7 ? ' MOVED THIS WEEK' : ` MOVED IN THE LAST ${days} DAYS`;
319
+ if (recentTotal === 0) {
320
+ lines.push(fit([[movedTitle, chalk.cyan.bold], [' nothing moved', chalk.yellow]], width));
321
+ } else {
322
+ lines.push(fit([
323
+ [movedTitle, chalk.cyan.bold], [' '], [String(recentTotal), chalk.bold],
324
+ [recentTotal > recentShown.length ? ` streams active, newest ${recentShown.length} shown` : ' streams active, newest first', chalk.dim],
325
+ ], width));
326
+ const evW = Math.max(...recentShown.map((s) => `${s.events ?? 0} events`.length));
327
+ const rNameW = Math.max(12, Math.min(48, width - (LEAD + 2 + idW + 2 + 2 + evW + 2 + 3)));
328
+ for (const s of recentShown) {
329
+ const ev = `${s.events ?? 0} event${s.events === 1 ? '' : 's'}`;
330
+ lines.push(fit([
331
+ [' '.repeat(LEAD)], glyphSeg(s.status), [' '],
332
+ [pad(truncate(s.id, idW), idW), chalk.cyan], [' '],
333
+ [pad(truncate(s.name, rNameW), rNameW)], [' '],
334
+ [padStart(ev, evW)], [' '],
335
+ [padStart(relativeAge(s.last_activity_at, now), 3)],
336
+ ], width));
337
+ }
338
+ }
339
+ lines.push('');
340
+
341
+ // stale
342
+ if (stale.count > 0) {
343
+ const segs = [[' STALE', chalk.yellow.bold], [' '], [String(stale.count), chalk.bold], [` in-progress stream${stale.count === 1 ? '' : 's'} quiet >14d`]];
344
+ const oldest = (stale.oldest || []).slice(0, BRIEF_CAPS.staleOldest);
345
+ if (oldest.length) {
346
+ let used = segs.reduce((n, [t]) => n + t.length, 0) + ' oldest: '.length;
347
+ const items = [];
348
+ for (const o of oldest) {
349
+ const txt = `${o.id} ${relativeAge(o.last_activity_at, now)}`;
350
+ if (used + txt.length + 2 > width && items.length) break;
351
+ items.push(txt);
352
+ used += txt.length + 2;
353
+ }
354
+ segs.push([' oldest: ', chalk.dim]);
355
+ items.forEach((t, i) => {
356
+ const [id, age] = t.split(' ');
357
+ segs.push([id, chalk.cyan], [` ${age}`], ...(i < items.length - 1 ? [[', ', chalk.dim]] : []));
358
+ });
359
+ }
360
+ lines.push(fit(segs, width));
361
+ } else {
362
+ lines.push(fit([[' STALE', chalk.green.bold], [' none, every in-progress stream moved in the last 14 days', chalk.green]], width));
363
+ }
364
+
365
+ lines.push(fit([[' next ', chalk.dim], ['quox plan show <id>', chalk.cyan], [' · ', chalk.dim], ['quox plan focus --all', chalk.cyan], [' · ', chalk.dim], ['quox plan watch', chalk.cyan]], width));
366
+ return lines;
367
+ }
368
+
369
+ /** Compact unmet-deps label: "after VAULT +1" (first id capped at 14 chars). */
370
+ export function depsText(deps) {
371
+ if (!deps || deps.length === 0) return '';
372
+ return `after ${truncate(deps[0], 14)}${deps.length > 1 ? ` +${deps.length - 1}` : ''}`;
373
+ }
374
+
375
+ function oneLine(text) {
376
+ return String(text || '').replace(/\s+/g, ' ').trim();
377
+ }
378
+
379
+ // ── capped focus ─────────────────────────────────────────────────────────
380
+
381
+ export const FOCUS_IN_FLIGHT_CAP = 12;
382
+
383
+ const NEEDS_STATUSES = new Set(['BLOCKED', 'DECISION_NEEDED']);
384
+
385
+ function byTierThenPriority(a, b) {
386
+ return (a.tier ?? 99) - (b.tier ?? 99) || (a.priority ?? 1e9) - (b.priority ?? 1e9) || String(a.id).localeCompare(String(b.id));
387
+ }
388
+
389
+ /**
390
+ * The capped focus lens from the existing `?lens=focus` payload:
391
+ * Needs you (every blocked + decision, any level), In flight (top 12 of the
392
+ * rest by priority), and a one-line stale count. opts: { width, now, roadmapId, cap }.
393
+ */
394
+ export function renderFocusCapped(payload, opts = {}) {
395
+ const width = opts.width || 100;
396
+ const now = opts.now ?? Date.now();
397
+ const cap = opts.cap || FOCUS_IN_FLIGHT_CAP;
398
+ const streams = (payload && payload.streams) || [];
399
+
400
+ const needs = [];
401
+ const rest = [];
402
+ // Substreams count everywhere (needs, in flight, stale) so focus agrees with
403
+ // the brief, whose totals cover every stream in the roadmap.
404
+ for (const s of streams) {
405
+ if (NEEDS_STATUSES.has(s.status)) needs.push({ ...s, parent: null });
406
+ else rest.push({ ...s, parent: null });
407
+ for (const sub of s.substreams || []) {
408
+ if (NEEDS_STATUSES.has(sub.status)) needs.push({ ...sub, parent: s.id });
409
+ else if (sub.status === 'IN_PROGRESS') rest.push({ ...sub, parent: s.id });
410
+ }
411
+ }
412
+ needs.sort(byTierThenPriority);
413
+ rest.sort((a, b) => (a.priority ?? 1e9) - (b.priority ?? 1e9));
414
+ const inFlight = rest.slice(0, cap);
415
+ const staleCount = rest.filter((s) => (s.focus_reasons || []).includes('stale')).length;
416
+
417
+ const idW = idWidth([...needs, ...inFlight]);
418
+ const nameW = Math.max(12, Math.min(48, width - (LEAD + 2 + idW + 2 + 2 + 8 + 2 + 3 + 2 + 3) - 14));
419
+
420
+ const lines = [];
421
+ lines.push(fit([[' QUOXPLAN', chalk.cyan.bold], [' '], [opts.roadmapId || payload?.roadmap_id || '', chalk.bold], [' focus', chalk.dim]], width));
422
+ lines.push('');
423
+
424
+ if (needs.length === 0) {
425
+ lines.push(fit([[' NEEDS YOU', chalk.cyan.bold], [' nothing blocked or waiting on a decision', chalk.green]], width));
426
+ } else {
427
+ lines.push(fit([[' NEEDS YOU', chalk.cyan.bold], [' '], [String(needs.length), chalk.red.bold], [' blocked or waiting on a decision', chalk.dim]], width));
428
+ for (const s of needs) {
429
+ const isDecision = s.status === 'DECISION_NEEDED';
430
+ lines.push(fit([
431
+ [' '.repeat(LEAD)], glyphSeg(s.status), [' '],
432
+ [pad(truncate(s.id, idW), idW), chalk.cyan], [' '],
433
+ [pad(truncate(s.name, nameW), nameW)], [' '],
434
+ [pad(isDecision ? 'DECISION' : 'BLOCKED', 8), isDecision ? chalk.magenta.bold : chalk.red.bold], [' '],
435
+ [pad(`T${s.tier ?? '?'}`, 3)], [' '],
436
+ [padStart(relativeAge(s.updated_at, now), 3)],
437
+ ...(s.parent ? [[' in ', chalk.dim], [s.parent, chalk.cyan]] : []),
438
+ ], width));
439
+ }
440
+ }
441
+ lines.push('');
442
+
443
+ if (inFlight.length === 0) {
444
+ lines.push(fit([[' IN FLIGHT', chalk.cyan.bold], [' nothing in progress', chalk.dim]], width));
445
+ } else {
446
+ lines.push(fit([
447
+ [' IN FLIGHT', chalk.cyan.bold], [' '], [String(inFlight.length), chalk.bold],
448
+ [rest.length > inFlight.length ? ` of ${rest.length} by priority` : ' by priority', chalk.dim],
449
+ ], width));
450
+ for (const s of inFlight) {
451
+ const reasons = (s.focus_reasons || []).filter((r) => r !== 'in_progress');
452
+ lines.push(fit([
453
+ [padStart(`#${s.priority ?? '?'}`, LEAD - 1) + ' ', chalk.bold], glyphSeg(s.status), [' '],
454
+ [pad(truncate(s.id, idW), idW), chalk.cyan], [' '],
455
+ [pad(truncate(s.name, nameW), nameW)], [' '],
456
+ [pad('', 8)], [' '],
457
+ [pad(`T${s.tier ?? '?'}`, 3)], [' '],
458
+ [padStart(relativeAge(s.updated_at, now), 3)],
459
+ ...(s.parent ? [[' in ', chalk.dim], [s.parent, chalk.cyan]] : []),
460
+ ...reasons.flatMap((r) => [[' '], [r, r === 'stale' ? chalk.yellow : chalk.cyan]]),
461
+ ], width));
462
+ }
463
+ }
464
+ lines.push('');
465
+
466
+ if (staleCount > 0) {
467
+ lines.push(fit([[' STALE', chalk.yellow.bold], [' '], [String(staleCount), chalk.bold], [` in-progress stream${staleCount === 1 ? '' : 's'} quiet >14d`], [' full list: quox plan focus --all', chalk.dim]], width));
468
+ }
469
+ lines.push(fit([[' ' + STATUS_LEGEND, chalk.dim]], width));
470
+ return lines;
471
+ }
472
+
473
+ // ── watch: diff successive briefs ────────────────────────────────────────
474
+
475
+ function indexBrief(brief) {
476
+ const map = new Map();
477
+ const b = brief || {};
478
+ const put = (s, extra) => {
479
+ if (!s || !s.id) return;
480
+ const cur = map.get(s.id) || { id: s.id };
481
+ for (const [k, v] of Object.entries({ ...s, ...extra })) if (v !== undefined) cur[k] = v;
482
+ map.set(s.id, cur);
483
+ };
484
+ for (const s of b.top || []) put(s);
485
+ for (const s of b.needs_you || []) put(s);
486
+ for (const s of b.recent || []) put(s, { events: s.events });
487
+ for (const s of b.unblockers || []) put(s);
488
+ return map;
489
+ }
490
+
491
+ /**
492
+ * Changes between two brief payloads: status flips, phase ticks, new notes,
493
+ * and fresh activity. Returns [{ kind, id, name, from, to, text }].
494
+ * Kinds: 'status' | 'phase' | 'note' | 'activity'.
495
+ */
496
+ export function diffBriefs(prev, next) {
497
+ if (!prev || !next) return [];
498
+ const a = indexBrief(prev);
499
+ const b = indexBrief(next);
500
+ const changes = [];
501
+ let attributedPhases = 0;
502
+
503
+ for (const [id, n] of b) {
504
+ const p = a.get(id);
505
+ if (!p) continue;
506
+ let changed = false;
507
+ if (p.status && n.status && p.status !== n.status) {
508
+ changes.push({ kind: 'status', id, name: n.name, from: p.status, to: n.status });
509
+ changed = true;
510
+ }
511
+ if (Number.isFinite(p.phases_done) && Number.isFinite(n.phases_done) && p.phases_done !== n.phases_done) {
512
+ changes.push({ kind: 'phase', id, name: n.name, from: p.phases_done, to: n.phases_done, total: n.phases_total });
513
+ attributedPhases += n.phases_done - p.phases_done;
514
+ changed = true;
515
+ }
516
+ const pn = p.latest_note;
517
+ const nn = n.latest_note;
518
+ if (nn && nn.created_at && (!pn || pn.created_at !== nn.created_at) && (!pn || Date.parse(nn.created_at) >= Date.parse(pn.created_at))) {
519
+ changes.push({ kind: 'note', id, name: n.name, text: oneLine(nn.body) });
520
+ changed = true;
521
+ }
522
+ if (!changed && Number.isFinite(n.events) && Number.isFinite(p.events) && n.events > p.events) {
523
+ changes.push({ kind: 'activity', id, name: n.name, from: p.events, to: n.events });
524
+ }
525
+ }
526
+
527
+ // Streams that newly need you without being visible before.
528
+ const prevNeeds = new Set((prev.needs_you || []).map((s) => s.id));
529
+ for (const s of next.needs_you || []) {
530
+ if (!prevNeeds.has(s.id) && !a.has(s.id)) {
531
+ changes.push({ kind: 'status', id: s.id, name: s.name, from: null, to: s.status });
532
+ }
533
+ }
534
+ // New streams in the recent window.
535
+ for (const s of next.recent || []) {
536
+ if (!a.has(s.id) && !changes.some((c) => c.id === s.id)) {
537
+ changes.push({ kind: 'activity', id: s.id, name: s.name, from: 0, to: s.events });
538
+ }
539
+ }
540
+ // Roadmap-wide phase ticks we could not pin on a visible stream.
541
+ const pd = (next.totals?.phases_done ?? 0) - (prev.totals?.phases_done ?? 0);
542
+ if (pd - attributedPhases !== 0 && prev.totals && next.totals) {
543
+ changes.push({ kind: 'phase', id: null, name: null, from: prev.totals.phases_done, to: next.totals.phases_done, total: next.totals.phases_total, delta: pd - attributedPhases });
544
+ }
545
+ return changes;
546
+ }
547
+
548
+ /** One ticker line for a change. `at` is the ISO time it was seen. */
549
+ export function renderChange(change, { at, width = 100 } = {}) {
550
+ const t = [` ${hhmm(at)} `, chalk.dim];
551
+ const id = change.id ? [[change.id, chalk.cyan], [' ']] : [];
552
+ switch (change.kind) {
553
+ case 'status': {
554
+ const g = statusGlyph(change.to);
555
+ return fit([t, [g.glyph, g.color.bold], [' '], ...id,
556
+ change.from ? [`${change.from} → `] : ['now '],
557
+ [change.to, change.to === 'BLOCKED' ? chalk.red.bold : change.to === 'DECISION_NEEDED' ? chalk.magenta.bold : change.to === 'COMPLETE' ? chalk.green.bold : chalk.bold],
558
+ ], width);
559
+ }
560
+ case 'phase': {
561
+ const up = change.id ? change.to > change.from : (change.delta || 0) > 0;
562
+ const label = change.id
563
+ ? `phase ${change.from}/${change.total ?? '?'} → ${change.to}/${change.total ?? '?'}`
564
+ : `roadmap phases ${change.from} → ${change.to} of ${change.total ?? '?'}`;
565
+ return fit([t, [up ? '■' : '□', up ? chalk.green.bold : chalk.yellow.bold], [' '], ...id, [label]], width);
566
+ }
567
+ case 'note':
568
+ return fit([t, ['✎', chalk.magenta.bold], [' '], ...id, ['new note: ', chalk.dim], [change.text || '']], width);
569
+ case 'activity':
570
+ default:
571
+ return fit([t, ['•', chalk.cyan.bold], [' '], ...id, [`+${(change.to ?? 0) - (change.from ?? 0)} event${(change.to ?? 0) - (change.from ?? 0) === 1 ? '' : 's'}`]], width);
572
+ }
573
+ }
574
+
575
+ /** Ticker block: header + newest `max` changes (oldest of them first). */
576
+ export function renderTicker(seen, { since, width = 100, max = 8 } = {}) {
577
+ const lines = [''];
578
+ lines.push(fit([[' CHANGES SINCE YOU STARTED WATCHING', chalk.cyan.bold], [` ${hhmm(since)} UTC`, chalk.dim], [' Ctrl-C to exit', chalk.dim]], width));
579
+ if (!seen || seen.length === 0) {
580
+ lines.push(fit([[' none yet', chalk.dim]], width));
581
+ return lines;
582
+ }
583
+ for (const { change, at } of seen.slice(-max)) lines.push(renderChange(change, { at, width }));
584
+ if (seen.length > max) lines.push(fit([[` +${seen.length - max} earlier`, chalk.dim]], width));
585
+ return lines;
586
+ }
587
+
588
+ // ── show helpers ─────────────────────────────────────────────────────────
589
+
590
+ export const SHOW_NOTES_CAP = 5;
591
+ export const SHOW_PLAN_LINES_CAP = 40;
592
+
593
+ /** Newest-first notes, capped. Returns { notes, hidden }. */
594
+ export function capNotes(notes, { full = false, cap = SHOW_NOTES_CAP } = {}) {
595
+ const sorted = [...(notes || [])].sort((x, y) => String(y.created_at).localeCompare(String(x.created_at)));
596
+ if (full) return { notes: sorted, hidden: 0 };
597
+ return { notes: sorted.slice(0, cap), hidden: Math.max(0, sorted.length - cap) };
598
+ }
599
+
600
+ /** One note body for `show`: whitespace collapsed and cut at 280 chars unless full. */
601
+ export const SHOW_NOTE_CHARS_CAP = 280;
602
+ export function capNoteBody(body, { full = false, cap = SHOW_NOTE_CHARS_CAP } = {}) {
603
+ const text = String(body ?? '');
604
+ if (full) return text;
605
+ const flat = text.replace(/\s+/g, ' ').trim();
606
+ return flat.length <= cap ? flat : flat.slice(0, cap) + '…';
607
+ }
608
+
609
+ /** Plan body capped at N lines. Returns { body, hidden }. */
610
+ export function capPlan(body, { full = false, cap = SHOW_PLAN_LINES_CAP } = {}) {
611
+ const lines = String(body || '').split('\n');
612
+ if (full || lines.length <= cap) return { body: String(body || ''), hidden: 0 };
613
+ return { body: lines.slice(0, cap).join('\n'), hidden: lines.length - cap };
614
+ }