quoxcli 0.5.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.
- package/CHANGELOG.md +50 -1
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +266 -5
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +471 -53
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +65 -9
- package/lib/commands/wazuhIntake.js +46 -5
- package/lib/core/commandRegistrars.js +1 -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 +1 -1
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +257 -48
- 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 +4 -4
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- 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 +35 -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 +6 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/toolOutputShape.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { parsePath } from './quoxon/rows.js';
|
|
17
17
|
|
|
18
18
|
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
|
|
19
|
-
const GRAMMAR = 'use a.b, a[0], list[field=value] (exact), list[field~text] (contains, any case), list[*].field (that field of every item), list[*].{a,b} (several fields of every item, one call), list[field=value].name, count(list), or several paths at once: total,count(list)';
|
|
19
|
+
const GRAMMAR = 'use . (the whole result), a.b, a[0], list[field=value] (exact), list[field~text] (contains, any case), list[*].field (that field of every item), list[*].{a,b} (several fields of every item, one call), list[field=value].name, count(list), or several paths at once: total,count(list)';
|
|
20
20
|
export const DEFAULT_SAMPLE = 10;
|
|
21
21
|
const COUNT_KEY_RE = /^(status|state|type|kind|level|severity|health|healthy|enabled|active|category|source|platform|os|role|priority|result|outcome|connected|online)$/i;
|
|
22
22
|
|
|
@@ -36,6 +36,9 @@ function splitTopLevel(e) {
|
|
|
36
36
|
return parts;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// "." is the whole result, as in jq (SEER 2026-11-07: a model wrote --select . and was refused).
|
|
40
|
+
const selectSteps = (x) => (x.trim() === '.' ? [] : parsePath(x));
|
|
41
|
+
|
|
39
42
|
function parseSelect(expr) {
|
|
40
43
|
const e = String(expr ?? '').trim();
|
|
41
44
|
// GUIDES-FIX: several paths in one call ("total,active_total"): each must be a valid path on its own.
|
|
@@ -53,10 +56,10 @@ function parseSelect(expr) {
|
|
|
53
56
|
const fn = /^([A-Za-z_]+)\s*\(([\s\S]*)\)$/.exec(e);
|
|
54
57
|
if (fn) {
|
|
55
58
|
if (fn[1] !== 'count') return { error: `--select: unknown function ${fn[1]}() (only count); ${GRAMMAR}` };
|
|
56
|
-
const steps =
|
|
59
|
+
const steps = selectSteps(fn[2]);
|
|
57
60
|
return steps ? { fn: 'count', steps } : { error: `--select: count() needs a path; ${GRAMMAR}` };
|
|
58
61
|
}
|
|
59
|
-
const steps =
|
|
62
|
+
const steps = selectSteps(e);
|
|
60
63
|
return steps ? { steps } : { error: `--select: bad path ${JSON.stringify(e)}; ${GRAMMAR}` };
|
|
61
64
|
}
|
|
62
65
|
|