quoxcli 0.5.0 → 0.6.1
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/CHANGELOG.md +65 -1
- package/lib/api/endpoints/admin.js +21 -0
- package/lib/api/endpoints/calendarq.js +8 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lens.js +34 -0
- package/lib/api/endpoints/lucid.js +18 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +6 -7
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/v1.js +57 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +86 -0
- package/lib/commands/approvals.js +5 -1
- package/lib/commands/calendar.js +27 -0
- package/lib/commands/chat.js +1 -1
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/fleet.js +164 -0
- package/lib/commands/fleetRecipe.js +174 -0
- package/lib/commands/lens.js +268 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +423 -0
- package/lib/commands/mail.js +287 -7
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +73 -1
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +507 -54
- package/lib/commands/research.js +78 -0
- package/lib/commands/settings.js +7 -1
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +74 -9
- package/lib/commands/wazuhIntake.js +55 -6
- package/lib/core/commandRegistrars.js +2 -0
- package/lib/core/config.js +14 -1
- package/lib/core/offload.js +83 -0
- package/lib/core/output.js +29 -4
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/version.js +15 -2
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +258 -49
- package/lib/quoxon/approvalsView.js +3 -1
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/bang.js +7 -1
- package/lib/quoxon/commandShort.js +1 -1
- package/lib/quoxon/commands.js +5 -5
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- package/lib/quoxon/dragonCompanion.js +24 -12
- package/lib/quoxon/events.js +12 -1
- package/lib/quoxon/extensions.js +2 -0
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/inboxView.js +5 -5
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +2 -1
- package/lib/quoxon/main.js +8 -2
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/render.js +5 -2
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/secretShape.js +2 -0
- package/lib/quoxon/slash/broadcast.js +30 -6
- package/lib/quoxon/slash/broadcastRun.js +12 -6
- package/lib/quoxon/slash/cliLeaves.json +54 -0
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +41 -0
- package/lib/quoxon/slash/paletteController.js +6 -2
- package/lib/quoxon/slash/restart.js +2 -0
- package/lib/quoxon/slotChat.js +7 -3
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/whatsnew.jsonl +7 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
- package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
- package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
- package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
- package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
- package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
- package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
- package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
- package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
- package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
- package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
- package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
- package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
- package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
- package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
- package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
- package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
- package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
- package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
- package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
- package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
- package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
- package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
- package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
- package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
- package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
- package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
- package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
package/lib/commands/plan.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,7 +202,7 @@ 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
|
}
|
|
@@ -219,13 +219,13 @@ function renderListFooter(payload, lens) {
|
|
|
219
219
|
return [
|
|
220
220
|
'',
|
|
221
221
|
chalk.dim(` ${count}`),
|
|
222
|
-
chalk.dim(
|
|
222
|
+
chalk.dim(` ${STATUS_LEGEND} └ substream`),
|
|
223
223
|
];
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
// ── render: stream detail ───────────────────────────────────────────────
|
|
227
227
|
|
|
228
|
-
function renderStreamDetail(detail) {
|
|
228
|
+
function renderStreamDetail(detail, { full = false, now = Date.now() } = {}) {
|
|
229
229
|
const { stream, phases = [], plan, notes = [], depends_on: dependsOn = [], dependents = [] } = detail;
|
|
230
230
|
const lines = [];
|
|
231
231
|
const g = statusGlyph(stream.status);
|
|
@@ -274,18 +274,23 @@ function renderStreamDetail(detail) {
|
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
if (plan && plan.body) {
|
|
277
|
+
const { body, hidden } = capPlan(plan.body, { full });
|
|
277
278
|
lines.push('');
|
|
278
279
|
lines.push(chalk.cyan.bold(` Plan (v${plan.version})`));
|
|
279
280
|
lines.push('');
|
|
280
|
-
lines.push(renderMarkdownLite(
|
|
281
|
+
lines.push(renderMarkdownLite(body));
|
|
282
|
+
if (hidden) lines.push(chalk.dim(` +${hidden} more lines, --full to print the whole plan`));
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
if (notes.length) {
|
|
286
|
+
const { notes: shown, hidden } = capNotes(notes, { full });
|
|
284
287
|
lines.push('');
|
|
285
|
-
lines.push(chalk.cyan.bold(' Notes'));
|
|
286
|
-
for (const n of
|
|
287
|
-
|
|
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 })}`);
|
|
288
292
|
}
|
|
293
|
+
if (hidden) lines.push(chalk.dim(` +${hidden} older notes, --full to print every note`));
|
|
289
294
|
}
|
|
290
295
|
|
|
291
296
|
lines.push('');
|
|
@@ -383,6 +388,60 @@ function renderTimeline(timeline, opts = {}) {
|
|
|
383
388
|
return lines;
|
|
384
389
|
}
|
|
385
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
|
+
|
|
386
445
|
// ── render: stats / completed ────────────────────────────────────────────
|
|
387
446
|
// Fed by GET /roadmaps/:id/export (the same STRATEGIC_ROADMAP.json-shaped
|
|
388
447
|
// doc the retired roadmap-cli used to read from disk). Adapted from the
|
|
@@ -413,7 +472,7 @@ function renderStats(doc) {
|
|
|
413
472
|
|
|
414
473
|
const lines = [];
|
|
415
474
|
lines.push('');
|
|
416
|
-
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)}`) : ''));
|
|
417
476
|
lines.push(' ' + '-'.repeat(50));
|
|
418
477
|
lines.push(` ${chalk.green('✓ Completed')} ${completed.length} streams`);
|
|
419
478
|
lines.push(` ${chalk.yellow('● In progress')} ${inProgress} streams`);
|
|
@@ -421,9 +480,21 @@ function renderStats(doc) {
|
|
|
421
480
|
if (blocked) lines.push(` ${chalk.red('✗ Blocked')} ${blocked} streams`);
|
|
422
481
|
if (decisions) lines.push(` ${chalk.magenta('? Needs decision')} ${decisions} streams`);
|
|
423
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.
|
|
424
486
|
lines.push(
|
|
425
|
-
` Phase progress
|
|
487
|
+
` Phase progress (open streams) ${progressBar(donePhases, totalPhases || 1)} ${chalk.dim(`(${donePhases}/${totalPhases})`)}`
|
|
426
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
|
+
}
|
|
427
498
|
lines.push(` Tests shipped ${totalTests} ${chalk.dim('(completed streams)')}`);
|
|
428
499
|
lines.push('');
|
|
429
500
|
return lines;
|
|
@@ -485,6 +556,91 @@ async function resolveRoadmapId(client, explicit, orgLabel) {
|
|
|
485
556
|
return roadmaps[0].id;
|
|
486
557
|
}
|
|
487
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
|
+
|
|
488
644
|
function printJson(data) {
|
|
489
645
|
console.log(JSON.stringify(data, null, 2));
|
|
490
646
|
}
|
|
@@ -513,8 +669,15 @@ function coerceUpdateValue(field, value) {
|
|
|
513
669
|
|
|
514
670
|
// ── registration ─────────────────────────────────────────────────────────
|
|
515
671
|
|
|
672
|
+
async function readProcessStdin() {
|
|
673
|
+
const chunks = [];
|
|
674
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
675
|
+
return Buffer.concat(chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c))).toString('utf8');
|
|
676
|
+
}
|
|
677
|
+
|
|
516
678
|
export function registerPlanCommands(program, ctx) {
|
|
517
679
|
const { client, auth } = ctx;
|
|
680
|
+
const readStdin = ctx.readStdin || readProcessStdin;
|
|
518
681
|
|
|
519
682
|
const plan = program
|
|
520
683
|
.command('plan')
|
|
@@ -524,11 +687,14 @@ export function registerPlanCommands(program, ctx) {
|
|
|
524
687
|
|
|
525
688
|
plan
|
|
526
689
|
.command('roadmaps')
|
|
527
|
-
.description('List roadmaps with status counts')
|
|
528
|
-
.
|
|
690
|
+
.description('List roadmaps with status counts (archived roadmaps hidden unless --archived)')
|
|
691
|
+
.option('--archived', 'include archived roadmaps (marked "archived")')
|
|
692
|
+
.action(async (options = {}) => {
|
|
529
693
|
auth.requireAuth();
|
|
530
694
|
const ep = resolveEndpoint('quoxplan.roadmaps.list');
|
|
531
|
-
const data =
|
|
695
|
+
const data = options.archived
|
|
696
|
+
? await client.get(ep.path, { service: ep.service, query: { include_archived: '1' } })
|
|
697
|
+
: await client.get(ep.path, { service: ep.service });
|
|
532
698
|
if (isJsonOutput(program)) return printJson(data);
|
|
533
699
|
console.log(renderRoadmapsList(data.roadmaps || []).join('\n'));
|
|
534
700
|
});
|
|
@@ -540,7 +706,7 @@ export function registerPlanCommands(program, ctx) {
|
|
|
540
706
|
// standalone verb in its own right so nothing has to shell out to onboard.
|
|
541
707
|
const roadmap = plan
|
|
542
708
|
.command('roadmap')
|
|
543
|
-
.description('Roadmap-level operations (create a roadmap)');
|
|
709
|
+
.description('Roadmap-level operations (create, archive, unarchive a roadmap)');
|
|
544
710
|
|
|
545
711
|
roadmap
|
|
546
712
|
.command('create')
|
|
@@ -579,6 +745,25 @@ export function registerPlanCommands(program, ctx) {
|
|
|
579
745
|
printSuccess(`Created roadmap ${created.id || options.id} for org ${orgId}`);
|
|
580
746
|
});
|
|
581
747
|
|
|
748
|
+
// QUOXPLAN-TERMINAL (gripe quoxai/quox#1586): archive hides a finished
|
|
749
|
+
// roadmap from `plan roadmaps` and `plan search` and freezes its streams
|
|
750
|
+
// (writes answer 409); its history stays readable by id. Not delete.
|
|
751
|
+
for (const [verb, endpointKey, done, help] of [
|
|
752
|
+
['archive', 'quoxplan.roadmap.archive', 'Archived', 'Archive a roadmap: hide it from lists and search, refuse writes to its streams (history stays readable by id)'],
|
|
753
|
+
['unarchive', 'quoxplan.roadmap.unarchive', 'Unarchived', 'Unarchive a roadmap: back in lists and search, writes allowed again'],
|
|
754
|
+
]) {
|
|
755
|
+
roadmap
|
|
756
|
+
.command(`${verb} <roadmapId>`)
|
|
757
|
+
.description(help)
|
|
758
|
+
.action(async (roadmapId) => {
|
|
759
|
+
auth.requireAuth();
|
|
760
|
+
const ep = resolveEndpoint(endpointKey, { roadmapId });
|
|
761
|
+
const data = await client.post(ep.path, { service: ep.service, body: { actor: 'quox-cli' } });
|
|
762
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
763
|
+
printSuccess(`${done} roadmap ${data.id || roadmapId}`);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
582
767
|
// ── search ───────────────────────────────────────────────────────────
|
|
583
768
|
|
|
584
769
|
plan
|
|
@@ -606,6 +791,8 @@ export function registerPlanCommands(program, ctx) {
|
|
|
606
791
|
.command('list [roadmap]')
|
|
607
792
|
.description('Tier-grouped stream list (default roadmap: first by name)')
|
|
608
793
|
.option('--lens <lens>', 'focus | tier | all', 'tier')
|
|
794
|
+
.option('--scope <scope>', SCOPE_HELP)
|
|
795
|
+
.option('--dev-server <hostId>', DEV_SERVER_HELP)
|
|
609
796
|
.action(async (roadmapArg, options) => {
|
|
610
797
|
auth.requireAuth();
|
|
611
798
|
if (!VALID_LENSES.includes(options.lens)) {
|
|
@@ -613,9 +800,9 @@ export function registerPlanCommands(program, ctx) {
|
|
|
613
800
|
}
|
|
614
801
|
const roadmapId = await resolveRoadmapId(client, roadmapArg, describeActiveOrg(auth, program));
|
|
615
802
|
const ep = resolveEndpoint('quoxplan.roadmap.streams.list', { roadmapId });
|
|
616
|
-
const data = await client.get(ep.path, { service: ep.service, query: { lens: options.lens } });
|
|
803
|
+
const data = await client.get(ep.path, { service: ep.service, query: { lens: options.lens, ...filterQuery(options) } });
|
|
617
804
|
if (isJsonOutput(program)) return printJson(data);
|
|
618
|
-
console.log(`\n ${roadmapId} (lens: ${options.lens})`);
|
|
805
|
+
console.log(`\n ${roadmapId} (lens: ${options.lens}${filterLabel(options)})`);
|
|
619
806
|
console.log(renderStreamList(data, { lens: options.lens }).join('\n'));
|
|
620
807
|
console.log(renderListFooter(data, options.lens).join('\n'));
|
|
621
808
|
});
|
|
@@ -624,23 +811,140 @@ export function registerPlanCommands(program, ctx) {
|
|
|
624
811
|
|
|
625
812
|
plan
|
|
626
813
|
.command('focus [roadmap]')
|
|
627
|
-
.description('
|
|
628
|
-
.
|
|
814
|
+
.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)')
|
|
815
|
+
.option('--all', 'print the full focus list (every P0, blocked, decision, in-flight and stale stream)')
|
|
816
|
+
.option('--scope <scope>', SCOPE_HELP)
|
|
817
|
+
.option('--dev-server <hostId>', DEV_SERVER_HELP)
|
|
818
|
+
.action(async (roadmapArg, options) => {
|
|
629
819
|
auth.requireAuth();
|
|
630
820
|
const roadmapId = await resolveRoadmapId(client, roadmapArg, describeActiveOrg(auth, program));
|
|
631
821
|
const ep = resolveEndpoint('quoxplan.roadmap.streams.list', { roadmapId });
|
|
632
|
-
const data = await client.get(ep.path, { service: ep.service, query: { lens: 'focus' } });
|
|
822
|
+
const data = await client.get(ep.path, { service: ep.service, query: { lens: 'focus', ...filterQuery(options) } });
|
|
633
823
|
if (isJsonOutput(program)) return printJson(data);
|
|
634
|
-
|
|
635
|
-
|
|
824
|
+
if (options.all) {
|
|
825
|
+
console.log(`\n ${roadmapId} (focus, all${filterLabel(options)})`);
|
|
826
|
+
console.log(renderStreamList(data, { lens: 'focus' }).join('\n'));
|
|
827
|
+
console.log(renderListFooter(data, 'focus').join('\n'));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
console.log('');
|
|
831
|
+
if (filterLabel(options)) console.log(chalk.dim(` filtered by ${filterLabel(options).slice(2)}`));
|
|
832
|
+
console.log(renderFocusCapped(data, { roadmapId, width: termWidth() }).join('\n'));
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// ── brief ────────────────────────────────────────────────────────────
|
|
836
|
+
// One screen: what to work on, what needs you, what moved, what is stale.
|
|
837
|
+
// Fed by the server's brief computation so CLI and chat give one answer.
|
|
838
|
+
|
|
839
|
+
const runBrief = async (roadmapArg, options = {}) => {
|
|
840
|
+
auth.requireAuth();
|
|
841
|
+
const top = intInRange(options.top, BRIEF_TOP, '--top');
|
|
842
|
+
const days = intInRange(options.days, BRIEF_DAYS, '--days');
|
|
843
|
+
const orgLabel = describeActiveOrg(auth, program);
|
|
844
|
+
const roadmapId = await resolveRoadmapId(client, roadmapArg, orgLabel);
|
|
845
|
+
const data = await fetchBrief(client, roadmapId, { top, days }, orgLabel);
|
|
846
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
847
|
+
console.log('');
|
|
848
|
+
console.log(renderBrief(data, { width: termWidth(), top, days }).join('\n'));
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
plan
|
|
852
|
+
.command('brief [roadmap]')
|
|
853
|
+
.description('One-screen roadmap brief: top priorities, what needs you, what to unblock, what moved, what is stale')
|
|
854
|
+
.option('--top <n>', `how many top priorities to show (${BRIEF_TOP.min} to ${BRIEF_TOP.max}, default ${BRIEF_TOP.def})`)
|
|
855
|
+
.option('--days <d>', `window for "moved" in days (${BRIEF_DAYS.min} to ${BRIEF_DAYS.max}, default ${BRIEF_DAYS.def})`)
|
|
856
|
+
.action(runBrief);
|
|
857
|
+
|
|
858
|
+
// Bare `quox plan` prints the brief for the default roadmap.
|
|
859
|
+
plan
|
|
860
|
+
.allowExcessArguments(true)
|
|
861
|
+
.action(async (_opts, cmd) => {
|
|
862
|
+
const extra = (cmd && cmd.args) || [];
|
|
863
|
+
if (extra.length) {
|
|
864
|
+
throw new Error(`unknown command 'plan ${extra[0]}'. See \`quox plan --help\`.`);
|
|
865
|
+
}
|
|
866
|
+
await runBrief(undefined, {});
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// ── watch ────────────────────────────────────────────────────────────
|
|
870
|
+
|
|
871
|
+
plan
|
|
872
|
+
.command('watch [roadmap]')
|
|
873
|
+
.description('Live board: redraws the brief every interval and ticks off changes since you started watching (Ctrl-C exits)')
|
|
874
|
+
.option('--interval <sec>', `seconds between refreshes (at least ${WATCH_INTERVAL.min}, default ${WATCH_INTERVAL.def})`)
|
|
875
|
+
.option('--top <n>', `how many top priorities to show (default ${BRIEF_TOP.def})`)
|
|
876
|
+
.option('--days <d>', `window for "moved" in days (default ${BRIEF_DAYS.def})`)
|
|
877
|
+
.action(async (roadmapArg, options) => {
|
|
878
|
+
auth.requireAuth();
|
|
879
|
+
const interval = intInRange(options.interval, WATCH_INTERVAL, '--interval');
|
|
880
|
+
const top = intInRange(options.top, BRIEF_TOP, '--top');
|
|
881
|
+
const days = intInRange(options.days, BRIEF_DAYS, '--days');
|
|
882
|
+
const orgLabel = describeActiveOrg(auth, program);
|
|
883
|
+
const roadmapId = await resolveRoadmapId(client, roadmapArg, orgLabel);
|
|
884
|
+
const first = await fetchBrief(client, roadmapId, { top, days }, orgLabel);
|
|
885
|
+
|
|
886
|
+
if (isJsonOutput(program) || !process.stdout.isTTY) {
|
|
887
|
+
if (isJsonOutput(program)) printJson(first);
|
|
888
|
+
else {
|
|
889
|
+
console.log('');
|
|
890
|
+
console.log(renderBrief(first, { width: termWidth(), top, days }).join('\n'));
|
|
891
|
+
console.log(chalk.dim('\n stdout is not a terminal, so this printed one brief instead of a live board.'));
|
|
892
|
+
}
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const since = new Date().toISOString();
|
|
897
|
+
const seen = [];
|
|
898
|
+
let prev = first;
|
|
899
|
+
let lastError = null;
|
|
900
|
+
let stopped = false;
|
|
901
|
+
let wake = null;
|
|
902
|
+
const draw = () => {
|
|
903
|
+
const width = termWidth();
|
|
904
|
+
const out = [
|
|
905
|
+
...renderBrief(prev, { width, top, days }),
|
|
906
|
+
...renderTicker(seen, { since, width }),
|
|
907
|
+
];
|
|
908
|
+
if (lastError) out.push(chalk.yellow(` last refresh failed: ${lastError}. Retrying every ${interval}s.`));
|
|
909
|
+
process.stdout.write('\x1b[2J\x1b[H' + out.join('\n') + '\n');
|
|
910
|
+
};
|
|
911
|
+
const stop = () => {
|
|
912
|
+
stopped = true;
|
|
913
|
+
if (wake) wake();
|
|
914
|
+
};
|
|
915
|
+
process.once('SIGINT', stop);
|
|
916
|
+
try {
|
|
917
|
+
draw();
|
|
918
|
+
while (!stopped) {
|
|
919
|
+
await new Promise((resolve) => {
|
|
920
|
+
const t = setTimeout(resolve, interval * 1000);
|
|
921
|
+
wake = () => { clearTimeout(t); resolve(); };
|
|
922
|
+
});
|
|
923
|
+
if (stopped) break;
|
|
924
|
+
try {
|
|
925
|
+
const next = await fetchBrief(client, roadmapId, { top, days }, orgLabel);
|
|
926
|
+
const at = new Date().toISOString();
|
|
927
|
+
for (const change of diffBriefs(prev, next)) seen.push({ change, at });
|
|
928
|
+
prev = next;
|
|
929
|
+
lastError = null;
|
|
930
|
+
} catch (err) {
|
|
931
|
+
lastError = err.message;
|
|
932
|
+
}
|
|
933
|
+
draw();
|
|
934
|
+
}
|
|
935
|
+
} finally {
|
|
936
|
+
process.removeListener('SIGINT', stop);
|
|
937
|
+
}
|
|
938
|
+
console.log(chalk.dim(`\n stopped watching ${roadmapId}. ${seen.length} change${seen.length === 1 ? '' : 's'} seen.`));
|
|
636
939
|
});
|
|
637
940
|
|
|
638
941
|
// ── show ─────────────────────────────────────────────────────────────
|
|
639
942
|
|
|
640
943
|
plan
|
|
641
944
|
.command('show <streamId>')
|
|
642
|
-
.description('
|
|
643
|
-
.
|
|
945
|
+
.description('Stream detail: header, phases checklist, deps, plan (first 40 lines) and the newest 5 notes (--full for everything)')
|
|
946
|
+
.option('--full', 'print the whole plan and every note')
|
|
947
|
+
.action(async (streamId, options) => {
|
|
644
948
|
auth.requireAuth();
|
|
645
949
|
const ep = resolveEndpoint('quoxplan.stream.get', { id: streamId });
|
|
646
950
|
let detail;
|
|
@@ -653,7 +957,7 @@ export function registerPlanCommands(program, ctx) {
|
|
|
653
957
|
throw err;
|
|
654
958
|
}
|
|
655
959
|
if (isJsonOutput(program)) return printJson(detail);
|
|
656
|
-
console.log(renderStreamDetail(detail).join('\n'));
|
|
960
|
+
console.log(renderStreamDetail(detail, { full: !!options.full }).join('\n'));
|
|
657
961
|
});
|
|
658
962
|
|
|
659
963
|
// ── timeline ─────────────────────────────────────────────────────────
|
|
@@ -662,15 +966,128 @@ export function registerPlanCommands(program, ctx) {
|
|
|
662
966
|
.command('timeline [roadmap]')
|
|
663
967
|
.description('ASCII Gantt: dependency-sequenced schedule, critical path bold, blocked edges marked')
|
|
664
968
|
.option('--weeks <n>', 'force the number of week columns shown', (v) => parseInt(v, 10))
|
|
969
|
+
// No --dev-server here: the timeline route reads only ?scope (roadmaps.js).
|
|
970
|
+
.option('--scope <scope>', SCOPE_HELP)
|
|
665
971
|
.action(async (roadmapArg, options) => {
|
|
666
972
|
auth.requireAuth();
|
|
667
973
|
const roadmapId = await resolveRoadmapId(client, roadmapArg, describeActiveOrg(auth, program));
|
|
668
974
|
const ep = resolveEndpoint('quoxplan.roadmap.timeline.get', { roadmapId });
|
|
669
|
-
const
|
|
975
|
+
const query = filterQuery(options, { devServer: false });
|
|
976
|
+
const data = await client.get(ep.path, Object.keys(query).length ? { service: ep.service, query } : { service: ep.service });
|
|
670
977
|
if (isJsonOutput(program)) return printJson(data);
|
|
671
|
-
|
|
978
|
+
const title = options.scope ? `${roadmapId} (scope: ${options.scope})` : roadmapId;
|
|
979
|
+
console.log(renderTimeline(data.timeline, { weeks: options.weeks, title }).join('\n'));
|
|
672
980
|
});
|
|
673
981
|
|
|
982
|
+
// ── followups / followup (legacy hidden: actions / action) ──────────
|
|
983
|
+
// The dashboard's follow-ups panel, from the terminal. `followups` lists
|
|
984
|
+
// (roadmap-wide, or one stream with --stream); `followup add|done|status`
|
|
985
|
+
// writes. `followup` is a pure group with no action of its own, so every
|
|
986
|
+
// leaf classes cleanly as read or write in the chat CLI policy.
|
|
987
|
+
// Owner 2026-11-08: renamed from `actions` / `action` because those
|
|
988
|
+
// collided with the Action Tracker's `quox actions`. The old names stay
|
|
989
|
+
// registered as hidden aliases (same handlers, old wording) so existing
|
|
990
|
+
// scripts keep working; `quox plan --help` lists only the follow-up verbs.
|
|
991
|
+
|
|
992
|
+
const followupFamilies = [
|
|
993
|
+
{ list: 'followups', group: 'followup', idArg: 'followupId', noun: FOLLOWUP_NOUN, hidden: false },
|
|
994
|
+
{ list: 'actions', group: 'action', idArg: 'actionId', noun: LEGACY_ACTION_NOUN, hidden: true },
|
|
995
|
+
];
|
|
996
|
+
|
|
997
|
+
for (const fam of followupFamilies) {
|
|
998
|
+
const { noun } = fam;
|
|
999
|
+
// Hidden on the legacy group AND its subcommands, so the leaf tree flags every legacy leaf hidden.
|
|
1000
|
+
const cmdOpts = fam.hidden ? { hidden: true } : {};
|
|
1001
|
+
|
|
1002
|
+
plan
|
|
1003
|
+
.command(`${fam.list} [roadmap]`, cmdOpts)
|
|
1004
|
+
.description(`List ${noun.many}: roadmap-wide (default roadmap: first by name), or one stream with --stream`)
|
|
1005
|
+
.option('--stream <streamId>', `list one stream's ${noun.many} instead of the whole roadmap`)
|
|
1006
|
+
.option('--status <status>', `only items in this status (${VALID_ACTION_STATUSES.join(', ')})`)
|
|
1007
|
+
.action(async (roadmapArg, options) => {
|
|
1008
|
+
auth.requireAuth();
|
|
1009
|
+
const status = options.status ? parseActionStatus(options.status, '--status') : null;
|
|
1010
|
+
if (roadmapArg && options.stream) {
|
|
1011
|
+
throw new Error('give either a roadmap or --stream <streamId>, not both');
|
|
1012
|
+
}
|
|
1013
|
+
const orgLabel = describeActiveOrg(auth, program);
|
|
1014
|
+
let data;
|
|
1015
|
+
let items;
|
|
1016
|
+
if (options.stream) {
|
|
1017
|
+
const ep = resolveEndpoint('quoxplan.stream.actionItems.list', { id: options.stream });
|
|
1018
|
+
try {
|
|
1019
|
+
data = await client.get(ep.path, { service: ep.service });
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
if (err instanceof QuoxApiError && err.status === 404) {
|
|
1022
|
+
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);
|
|
1023
|
+
}
|
|
1024
|
+
throw err;
|
|
1025
|
+
}
|
|
1026
|
+
// The per-stream route has no status filter, so filter here.
|
|
1027
|
+
items = (data.action_items || []).filter((a) => !status || a.status === status);
|
|
1028
|
+
if (status) data = { ...data, status, action_items: items };
|
|
1029
|
+
} else {
|
|
1030
|
+
const roadmapId = await resolveRoadmapId(client, roadmapArg, orgLabel);
|
|
1031
|
+
const ep = resolveEndpoint('quoxplan.roadmap.actionItems.list', { roadmapId });
|
|
1032
|
+
try {
|
|
1033
|
+
data = await client.get(ep.path, status ? { service: ep.service, query: { status } } : { service: ep.service });
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
if (err instanceof QuoxApiError && err.status === 404) {
|
|
1036
|
+
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);
|
|
1037
|
+
}
|
|
1038
|
+
throw err;
|
|
1039
|
+
}
|
|
1040
|
+
items = data.action_items || [];
|
|
1041
|
+
}
|
|
1042
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
1043
|
+
const where = options.stream || data.roadmap_id || roadmapArg;
|
|
1044
|
+
console.log(`\n ${where} (${noun.many}${status ? `, ${status}` : ''})`);
|
|
1045
|
+
console.log(renderActionItems(items, { status, noun }).join('\n'));
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
const group = plan
|
|
1049
|
+
.command(fam.group, cmdOpts)
|
|
1050
|
+
.description(`${noun.one[0].toUpperCase()}${noun.one.slice(1)} writes: add a manual item, mark one done, or set its status`);
|
|
1051
|
+
|
|
1052
|
+
group
|
|
1053
|
+
.command('add <streamId> <title...>', cmdOpts)
|
|
1054
|
+
.description(`Add a manual ${noun.one} to a stream`)
|
|
1055
|
+
.option('--due <date>', 'due date (ISO 8601, e.g. 2026-12-15)')
|
|
1056
|
+
.action(async (streamId, titleParts, options) => {
|
|
1057
|
+
auth.requireAuth();
|
|
1058
|
+
const title = titleParts.join(' ').trim();
|
|
1059
|
+
if (!title) throw new Error(`${noun.one} title is required`);
|
|
1060
|
+
const body = { title, actor: 'quox-cli' };
|
|
1061
|
+
if (options.due !== undefined) {
|
|
1062
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(options.due) || !Number.isFinite(Date.parse(options.due))) {
|
|
1063
|
+
throw new Error(`invalid --due "${options.due}" (expected an ISO 8601 date, e.g. 2026-12-15)`);
|
|
1064
|
+
}
|
|
1065
|
+
body.due_at = options.due;
|
|
1066
|
+
}
|
|
1067
|
+
const ep = resolveEndpoint('quoxplan.stream.actionItems.create', { id: streamId });
|
|
1068
|
+
const data = await client.post(ep.path, { service: ep.service, body });
|
|
1069
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
1070
|
+
printEvent(glyphLine('quoxplan', `${noun.glyph || noun.one} added`, { result: `${streamId} #${data.id} ${title}`, status: 'ok' }));
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
group
|
|
1074
|
+
.command(`done <${fam.idArg}>`, cmdOpts)
|
|
1075
|
+
.description(`Mark ${noun.a} DONE`)
|
|
1076
|
+
.action(async (actionIdArg) => {
|
|
1077
|
+
auth.requireAuth();
|
|
1078
|
+
await setActionStatus(client, program, actionIdArg, 'DONE', noun);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
group
|
|
1082
|
+
.command(`status <${fam.idArg}> <status>`, cmdOpts)
|
|
1083
|
+
.description(`Set ${noun.a}'s status (${VALID_ACTION_STATUSES.join(', ')})`)
|
|
1084
|
+
.action(async (actionIdArg, statusArg) => {
|
|
1085
|
+
auth.requireAuth();
|
|
1086
|
+
const status = parseActionStatus(statusArg);
|
|
1087
|
+
await setActionStatus(client, program, actionIdArg, status, noun);
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
674
1091
|
// ── status ───────────────────────────────────────────────────────────
|
|
675
1092
|
|
|
676
1093
|
plan
|
|
@@ -778,12 +1195,17 @@ export function registerPlanCommands(program, ctx) {
|
|
|
778
1195
|
|
|
779
1196
|
plan
|
|
780
1197
|
.command('plan <id>')
|
|
781
|
-
.description('Print raw plan markdown; --edit opens $EDITOR (PUT on save if changed); --history lists versions')
|
|
1198
|
+
.description('Print raw plan markdown; --file writes a new version from a file or stdin; --edit opens $EDITOR (PUT on save if changed); --history lists versions')
|
|
1199
|
+
.option('--file <path>', "write the plan body from a file ('-' reads stdin); a new version only if it changed")
|
|
782
1200
|
.option('--edit', 'open $EDITOR on the plan body, PUT on save if changed')
|
|
783
1201
|
.option('--history', 'list plan versions instead of printing the body')
|
|
784
1202
|
.action(async (id, options) => {
|
|
785
1203
|
auth.requireAuth();
|
|
786
1204
|
|
|
1205
|
+
if (options.file !== undefined && (options.edit || options.history)) {
|
|
1206
|
+
throw new Error('--file cannot be combined with --edit or --history');
|
|
1207
|
+
}
|
|
1208
|
+
|
|
787
1209
|
if (options.history) {
|
|
788
1210
|
const ep = resolveEndpoint('quoxplan.stream.plan.versions.list', { id });
|
|
789
1211
|
const data = await client.get(ep.path, { service: ep.service });
|
|
@@ -803,6 +1225,29 @@ export function registerPlanCommands(program, ctx) {
|
|
|
803
1225
|
const detail = await client.get(detailEp.path, { service: detailEp.service });
|
|
804
1226
|
const currentBody = (detail.plan && detail.plan.body) || '';
|
|
805
1227
|
|
|
1228
|
+
if (options.file !== undefined) {
|
|
1229
|
+
let newBody;
|
|
1230
|
+
if (options.file === '-') {
|
|
1231
|
+
newBody = await readStdin();
|
|
1232
|
+
} else {
|
|
1233
|
+
try {
|
|
1234
|
+
newBody = readFileSync(options.file, 'utf8');
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
throw new Error(`cannot read plan file ${options.file}: ${err.code || err.message}`);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (!String(newBody).trim()) throw new Error('plan body is empty; refusing to write an empty plan');
|
|
1240
|
+
if (newBody === currentBody) {
|
|
1241
|
+
console.log(chalk.dim(' No changes.'));
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const putEp = resolveEndpoint('quoxplan.stream.plan.put', { id });
|
|
1245
|
+
const data = await client.put(putEp.path, { service: putEp.service, body: { body: newBody, actor: 'quox-cli' } });
|
|
1246
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
1247
|
+
printSuccess(`${id}: plan updated to v${data.version}`);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
806
1251
|
if (options.edit) {
|
|
807
1252
|
const dir = mkdtempSync(join(tmpdir(), 'quox-plan-'));
|
|
808
1253
|
const file = join(dir, `${id}.md`);
|
|
@@ -870,7 +1315,7 @@ export function registerPlanCommands(program, ctx) {
|
|
|
870
1315
|
plan
|
|
871
1316
|
.command('add <json>')
|
|
872
1317
|
.description('Create a new stream from a JSON object (id, name required)')
|
|
873
|
-
.option('--roadmap <roadmap>', 'roadmap id (default: first roadmap)')
|
|
1318
|
+
.option('--roadmap <roadmap>', 'roadmap id (default: roadmap_id in the JSON, else the first roadmap)')
|
|
874
1319
|
.action(async (json, options) => {
|
|
875
1320
|
auth.requireAuth();
|
|
876
1321
|
let body;
|
|
@@ -880,7 +1325,15 @@ export function registerPlanCommands(program, ctx) {
|
|
|
880
1325
|
throw new Error(`invalid JSON: ${err.message}`);
|
|
881
1326
|
}
|
|
882
1327
|
body.actor = body.actor || 'quox-cli';
|
|
883
|
-
|
|
1328
|
+
// The roadmap comes from the URL, never the body. A roadmap_id in the JSON used to be silently ignored and the
|
|
1329
|
+
// stream landed in the FIRST roadmap (2026-11-09: a fixture seed filled the wrong roadmap). Honour it, and refuse
|
|
1330
|
+
// a disagreement with --roadmap rather than guess.
|
|
1331
|
+
const jsonRoadmap = typeof body.roadmap_id === 'string' && body.roadmap_id ? body.roadmap_id : null;
|
|
1332
|
+
delete body.roadmap_id;
|
|
1333
|
+
if (jsonRoadmap && options.roadmap && jsonRoadmap !== options.roadmap) {
|
|
1334
|
+
throw new Error(`the JSON says roadmap_id "${jsonRoadmap}" but --roadmap "${options.roadmap}": pass one, or make them agree`);
|
|
1335
|
+
}
|
|
1336
|
+
const roadmapId = await resolveRoadmapId(client, options.roadmap || jsonRoadmap, describeActiveOrg(auth, program));
|
|
884
1337
|
const ep = resolveEndpoint('quoxplan.roadmap.streams.create', { roadmapId });
|
|
885
1338
|
const data = await client.post(ep.path, { service: ep.service, body });
|
|
886
1339
|
if (isJsonOutput(program)) return printJson(data);
|