sizmo 0.4.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.
@@ -0,0 +1,213 @@
1
+ // commands/brief.mjs — Morning brief orchestrator. In-process: calls the 5 sub-collect()s on the
2
+ // SAME shared ctx. One http client, one rate budget, zero child-process spawning.
3
+ // NEEDS YOU TODAY uses rankActions from lib/prioritize.mjs — same ranker as ghl focus.
4
+ // READ-ONLY. Never writes, never sends, never charges.
5
+ import { collect as snapCollect } from './snapshot.mjs';
6
+ import { collect as triageCollect } from './triage.mjs';
7
+ import { collect as noshowCollect } from './noshow.mjs';
8
+ import { collect as pipeCollect } from './pipeline.mjs';
9
+ import { collect as arCollect } from './receivables.mjs';
10
+ import { rankActions, hasMixedCurrencies } from '../lib/prioritize.mjs';
11
+
12
+ export const meta = {
13
+ name: 'brief',
14
+ summary: 'morning brief — numbers + NEEDS YOU TODAY',
15
+ flags: [{ name: '--days', type: 'int', default: 7, desc: 'snapshot window in days' }],
16
+ readOnly: true,
17
+ };
18
+
19
+ // ── helpers ──────────────────────────────────────────────────────────────────
20
+ const SYM = { PHP: '₱', USD: '$', EUR: '€', GBP: '£' };
21
+ const m = (n, c = 'PHP') => !Number.isFinite(Number(n)) ? '—' : (SYM[c] || c + ' ') + Number(n || 0).toLocaleString('en-PH', { maximumFractionDigits: 0 });
22
+
23
+ // Parse an age string like "21d", "3h", "5m" back to ageDays
24
+ function parseAgeDays(str) {
25
+ if (typeof str === 'number') return str;
26
+ if (!str) return 0;
27
+ const match = String(str).match(/^(\d+(?:\.\d+)?)(d|h|m)$/i);
28
+ if (!match) return 0;
29
+ const n = Number(match[1]);
30
+ if (match[2] === 'd') return n;
31
+ if (match[2] === 'h') return Math.ceil(n / 24);
32
+ return Math.max(0, Math.ceil(n / 1440));
33
+ }
34
+
35
+ // Shape the 4 lane sources into rankActions input format.
36
+ // Called by both collect() (for the JSON envelope) and run() (for the TTY card).
37
+ function shapeLanes(sources, now) {
38
+ const { triage, noshow, pipeline: pipe, receivables: ar, bnp } = sources;
39
+
40
+ const deals = pipe?.__error ? [] : (pipe?.stuck || []).map(d => ({
41
+ contactId: d.contactId,
42
+ name: d.name || '(unknown)',
43
+ monetaryValue: Number(d.value) || 0,
44
+ ageDays: parseAgeDays(d.idle),
45
+ }));
46
+
47
+ const invoices = ar?.__error ? [] : (ar?.list || []).map(i => ({
48
+ contactId: i.id,
49
+ name: i.name || '(unknown)',
50
+ due: Number(i.due) || 0,
51
+ cur: i.cur || 'PHP',
52
+ ageDays: Number(i.age) || 0,
53
+ }));
54
+
55
+ const threads = triage?.__error ? [] : (triage?.threads || []).map(t => ({
56
+ contactId: t.contactId,
57
+ name: t.name || '(unknown)',
58
+ ageDays: parseAgeDays(t.waiting),
59
+ }));
60
+
61
+ const noshows = noshow?.__error ? [] : (noshow?.list || []).map(n => ({
62
+ contactId: n.contactId,
63
+ name: n.name || '(unknown)',
64
+ ageDays: Math.floor((now - new Date(n.when).getTime()) / 86400000),
65
+ }));
66
+
67
+ const neverBilled = bnp?.__error ? [] : (bnp?.neverBilled || []).map(b => ({
68
+ contactId: b.contactId,
69
+ name: b.name || '(unknown)',
70
+ estValue: 0,
71
+ ageDays: Math.floor((now - (b.lastSessionTs || 0)) / 86400000),
72
+ }));
73
+
74
+ return { deals, invoices, threads, noshows, neverBilled };
75
+ }
76
+
77
+ // Wrap a collect() so a throw → degraded sentinel instead of crashing the brief.
78
+ async function safe(name, fn, ctx) {
79
+ try {
80
+ return await fn();
81
+ } catch (err) {
82
+ const msg = `${name} failed — ${(err?.message || String(err)).split('\n')[0]}`;
83
+ ctx.out.warn(msg, { degraded: true });
84
+ return { __error: msg };
85
+ }
86
+ }
87
+
88
+ // ── collect: the composable data layer ───────────────────────────────────────
89
+ export async function collect(args, ctx) {
90
+ const DAYS = args.days != null ? args.days : 7;
91
+
92
+ // Fan-out all 5 sub-collects in parallel on the same ctx.
93
+ // Each uses ctx.cfg.loc / ctx.http / ctx.now — no creds duplication.
94
+ const [snap, triage, noshow, pipe, ar] = await Promise.all([
95
+ safe('snapshot', () => snapCollect({ days: DAYS }, ctx), ctx),
96
+ safe('triage', () => triageCollect({ days: 30, top: 100 }, ctx), ctx),
97
+ safe('noshow', () => noshowCollect({ days: 30, top: 100 }, ctx), ctx),
98
+ safe('pipeline', () => pipeCollect({ 'stuck-days': 7, top: 100 }, ctx), ctx),
99
+ safe('receivables',() => arCollect({ top: 100 }, ctx), ctx),
100
+ ]);
101
+
102
+ const loc = snap.location || triage.location || ctx.cfg.loc;
103
+
104
+ // Build the prioritised action list using rankActions (same ranker as ghl focus).
105
+ // Additive: keep count + recipe for backward compat; add money + age fields.
106
+ const lanes = shapeLanes({ triage, noshow, pipeline: pipe, receivables: ar }, ctx.now);
107
+ const { ranked, unknownValue } = rankActions(lanes);
108
+
109
+ // Build the actions array: ranked items first (money-ordered), then unknownValue items.
110
+ // Keep backward-compat fields (count, kind, recipe) — add money + age.
111
+ const actions = [];
112
+ for (const item of ranked) {
113
+ const recipeMap = { deal: 'pipeline', invoice: 'receivables', 'never-billed': 'booked-not-paid' };
114
+ actions.push({
115
+ count: 1,
116
+ kind: item.kind === 'deal' ? 'stuck-deals' : item.kind === 'invoice' ? 'receivables' : item.kind,
117
+ recipe: recipeMap[item.kind] || item.action.replace('ghl ', ''),
118
+ money: item.money,
119
+ cur: item.cur,
120
+ age: item.age,
121
+ inputs: item.inputs,
122
+ contact: item.contact,
123
+ name: item.name,
124
+ });
125
+ }
126
+ for (const item of unknownValue) {
127
+ const recipeMap = { 'waiting-reply': 'triage', noshow: 'noshow', 'never-billed': 'booked-not-paid' };
128
+ actions.push({
129
+ count: 1,
130
+ kind: item.kind,
131
+ recipe: recipeMap[item.kind] || item.action.replace('ghl ', ''),
132
+ money: null,
133
+ age: item.age,
134
+ inputs: item.inputs,
135
+ contact: item.contact,
136
+ name: item.name,
137
+ });
138
+ }
139
+
140
+ return {
141
+ location: loc,
142
+ days: DAYS,
143
+ snapshot: snap,
144
+ actions,
145
+ sources: { triage, noshow, pipeline: pipe, receivables: ar },
146
+ };
147
+ }
148
+
149
+ // ── run: bimodal output (JSON envelope OR TTY morning card) ──────────────────
150
+ export async function run(args, ctx) {
151
+ const DAYS = args.days != null ? args.days : 7;
152
+ const data = await collect(args, ctx);
153
+
154
+ // Machine mode: hand the combined document to the envelope
155
+ ctx.out.data(data);
156
+
157
+ // TTY mode: render the MORNING BRIEF card
158
+ ctx.out.card(() => {
159
+ const today = new Date().toLocaleDateString('en-US', {
160
+ timeZone: 'Asia/Manila', weekday: 'long', month: 'short', day: 'numeric',
161
+ });
162
+ const W = 64;
163
+ const bar = (ch = '─') => ch.repeat(W);
164
+ const pad = (s) => { const str = String(s); return str.length >= W ? str.slice(0, W) : str + ' '.repeat(W - str.length); };
165
+
166
+ ctx.out.line('\n╔' + bar('═') + '╗');
167
+ ctx.out.line('║' + pad(' MORNING BRIEF — ' + today) + '║');
168
+ ctx.out.line('║' + pad(' loc ' + data.location + ' · read-only') + '║');
169
+ ctx.out.line('╚' + bar('═') + '╝');
170
+
171
+ // THE NUMBERS
172
+ ctx.out.line('\n THE NUMBERS (last ' + DAYS + 'd)');
173
+ ctx.out.line(' ' + bar());
174
+ const snap = data.snapshot;
175
+ if (snap.__error) {
176
+ ctx.out.line(' ⚠ snapshot — ' + snap.__error);
177
+ } else {
178
+ for (const metric of (snap.metrics || [])) {
179
+ const v = metric.blocked ? "⚠ can't see (" + metric.blocker + ')' : metric.value;
180
+ ctx.out.line(' ' + String(metric.label).padEnd(16) + ' ' + v);
181
+ }
182
+ }
183
+
184
+ // NEEDS YOU TODAY — ordered by rankActions (same ranker as ghl focus)
185
+ const { triage, noshow, pipeline: pipe, receivables: ar } = data.sources;
186
+
187
+ ctx.out.line('\n NEEDS YOU TODAY');
188
+ ctx.out.line(' ' + bar());
189
+
190
+ // Surface blocked sources honestly — never fake a number
191
+ for (const [name, obj] of [['triage', triage], ['no-show', noshow], ['pipeline', pipe], ['receivables', ar]]) {
192
+ if (obj?.__error) ctx.out.line(` ⚠ ${name} — ${obj.__error}`);
193
+ }
194
+
195
+ const actions = data.actions || [];
196
+ if (!actions.length) {
197
+ ctx.out.line(' All clear — nobody waiting, nothing stuck, nothing owed. ✅');
198
+ } else {
199
+ actions.forEach((action, i) => {
200
+ const label = action.inputs || action.name || action.kind;
201
+ const recipe = `ghl ${action.recipe}`;
202
+ ctx.out.line(` ${i + 1}. ${label.padEnd(48)} → ${recipe}`);
203
+ });
204
+ }
205
+
206
+ ctx.out.line(' ' + bar());
207
+ ctx.out.line(' Ranked by money at stake (deal value, invoice amount due). Value-unknown items below.');
208
+ ctx.out.line(' Drill into any line with its recipe. Segment/tag on demand: ghl segment.');
209
+ ctx.out.line(' I draft + you approve every outward action. Money stays you, always.\n');
210
+ });
211
+
212
+ return 0;
213
+ }
@@ -0,0 +1,163 @@
1
+ // commands/focus.mjs — One ranked action queue by money at stake.
2
+ // Reuses the 5 existing collect()s on the shared cached ctx — inherits A's speed + cache.
3
+ // Ranks via lib/prioritize.mjs (single source of truth shared with brief).
4
+ // READ-ONLY. Never sends, charges, or writes.
5
+ import { collect as pipeCollect } from './pipeline.mjs';
6
+ import { collect as arCollect } from './receivables.mjs';
7
+ import { collect as triageCollect } from './triage.mjs';
8
+ import { collect as noshowCollect } from './noshow.mjs';
9
+ import { collect as bnpCollect } from './booked-not-paid.mjs';
10
+ import { rankActions, hasMixedCurrencies } from '../lib/prioritize.mjs';
11
+
12
+ export const meta = {
13
+ name: 'focus',
14
+ summary: 'one ranked to-do queue by money at stake',
15
+ flags: [
16
+ { name: '--top', type: 'int', default: 15, desc: 'max items to display' },
17
+ { name: '--stuck-days',type: 'int', default: 7, desc: 'idle threshold for stuck deals' },
18
+ ],
19
+ readOnly: true,
20
+ };
21
+
22
+ // Parse an age string like "21d", "3h", "5m" back to ageDays (fractional ok, floor to 0)
23
+ function parseAgeDays(str, nowMs) {
24
+ if (typeof str === 'number') return str;
25
+ if (!str) return 0;
26
+ const m = String(str).match(/^(\d+(?:\.\d+)?)(d|h|m)$/i);
27
+ if (!m) return 0;
28
+ const n = Number(m[1]);
29
+ if (m[2] === 'd') return n;
30
+ if (m[2] === 'h') return Math.ceil(n / 24);
31
+ return Math.max(0, Math.ceil(n / 1440));
32
+ }
33
+
34
+ // Wrap a collect() so a throw → degraded sentinel instead of crashing focus
35
+ async function safe(name, fn, ctx) {
36
+ try { return await fn(); }
37
+ catch (err) {
38
+ const msg = `${name} failed — ${(err?.message || String(err)).split('\n')[0]}`;
39
+ ctx.out.warn(msg, { degraded: true });
40
+ return { __error: msg };
41
+ }
42
+ }
43
+
44
+ // ── collect: shape all lanes into rankActions input, return ranked result ─────
45
+ export async function collect(args, ctx) {
46
+ const STUCK_DAYS = args['stuck-days'] ?? 7;
47
+ const NOW = ctx.now;
48
+
49
+ // Fan-out all 5 sub-collects in parallel on the same ctx (same http client, same cache)
50
+ const [pipe, ar, triage, noshow, bnp] = await Promise.all([
51
+ safe('pipeline', () => pipeCollect({ 'stuck-days': STUCK_DAYS, top: 200 }, ctx), ctx),
52
+ safe('receivables', () => arCollect({ top: 200 }, ctx), ctx),
53
+ safe('triage', () => triageCollect({ days: 30, top: 200 }, ctx), ctx),
54
+ safe('noshow', () => noshowCollect({ days: 30, top: 200 }, ctx), ctx),
55
+ safe('booked-not-paid',() => bnpCollect({ days: 30, top: 200 }, ctx), ctx),
56
+ ]);
57
+
58
+ const loc = pipe.location || ar.location || triage.location || ctx.cfg.loc;
59
+
60
+ // ── Shape lane data into rankActions input ────────────────────────────────
61
+
62
+ // deals: pipeline stuck — each has monetaryValue + idle string like "21d"
63
+ const deals = pipe.__error ? [] : (pipe.stuck || []).map(d => ({
64
+ contactId: d.contactId,
65
+ name: d.name || '(unknown)',
66
+ monetaryValue: Number(d.value) || 0,
67
+ ageDays: parseAgeDays(d.idle),
68
+ }));
69
+
70
+ // invoices: receivables list — already has due + cur + age (days)
71
+ const invoices = ar.__error ? [] : (ar.list || []).map(i => ({
72
+ contactId: i.id, // receivables uses i.id as the invoice id; contactId not in list shape
73
+ name: i.name || '(unknown)',
74
+ due: Number(i.due) || 0,
75
+ cur: i.cur || 'PHP',
76
+ ageDays: Number(i.age) || 0,
77
+ }));
78
+ // Fix: receivables.list has no contactId in the shape. Use invoice id as fallback key.
79
+ // The action points to 'ghl receivables' which shows the full list.
80
+
81
+ // threads: triage.threads — has waiting string like "3d"; no monetary value
82
+ const threads = triage.__error ? [] : (triage.threads || []).map(t => ({
83
+ contactId: t.contactId,
84
+ name: t.name || '(unknown)',
85
+ ageDays: parseAgeDays(t.waiting),
86
+ }));
87
+
88
+ // noshows: noshow.list — has when (ISO), compute ageDays
89
+ const noshows = noshow.__error ? [] : (noshow.list || []).map(n => ({
90
+ contactId: n.contactId,
91
+ name: n.name || '(unknown)',
92
+ ageDays: Math.floor((NOW - new Date(n.when).getTime()) / 86400000),
93
+ }));
94
+
95
+ // neverBilled: booked-not-paid.neverBilled — no estValue in the collect output shape
96
+ // (booked-not-paid detect they have sessions but no invoice — value unknown)
97
+ const neverBilled = bnp.__error ? [] : (bnp.neverBilled || []).map(b => ({
98
+ contactId: b.contactId,
99
+ name: b.name || '(unknown)',
100
+ estValue: 0, // no estimate available from this collect → goes to unknownValue group
101
+ ageDays: Math.floor((NOW - (b.lastSessionTs || 0)) / 86400000),
102
+ }));
103
+
104
+ const { ranked, unknownValue } = rankActions({ deals, invoices, threads, noshows, neverBilled });
105
+ const mixedCurrencies = hasMixedCurrencies(ranked);
106
+
107
+ return { location: loc, ranked, unknownValue, mixedCurrencies };
108
+ }
109
+
110
+ // ── run: bimodal output (JSON envelope OR TTY card) ───────────────────────────
111
+ export async function run(args, ctx) {
112
+ const TOP = args.top ?? 15;
113
+ const data = await collect(args, ctx);
114
+
115
+ ctx.out.data(data);
116
+
117
+ ctx.out.card(() => {
118
+ const W = 70;
119
+ const bar = (ch = '─') => ch.repeat(W);
120
+ ctx.out.line('\n FOCUS — ranked action queue by money at stake · loc ' + data.location);
121
+ ctx.out.line(' ' + bar());
122
+
123
+ if (!data.ranked.length && !data.unknownValue.length) {
124
+ ctx.out.line(' Nothing to action — all clear. ✅\n');
125
+ return;
126
+ }
127
+
128
+ // ── MONEY-RANKED items ──────────────────────────────────────────────────
129
+ if (data.ranked.length) {
130
+ ctx.out.line(' RANKED BY MONEY AT STAKE');
131
+ const show = data.ranked.slice(0, TOP);
132
+ show.forEach((item, i) => {
133
+ const label = (item.name || '(unknown)').slice(0, 24).padEnd(24);
134
+ ctx.out.line(` ${String(i + 1).padStart(2)}. ${label} ${item.inputs}`);
135
+ ctx.out.line(` → ${item.action} · contact ${item.contact}`);
136
+ });
137
+ if (data.ranked.length > TOP) ctx.out.line(` … +${data.ranked.length - TOP} more money items`);
138
+ ctx.out.line('');
139
+ }
140
+
141
+ // ── UNKNOWN VALUE items ─────────────────────────────────────────────────
142
+ if (data.unknownValue.length) {
143
+ ctx.out.line(' VALUE UNKNOWN — your call');
144
+ ctx.out.line(' (no money on record; sorted oldest first — may still be urgent)');
145
+ data.unknownValue.slice(0, TOP).forEach((item, i) => {
146
+ const label = (item.name || '(unknown)').slice(0, 24).padEnd(24);
147
+ ctx.out.line(` ${String(i + 1).padStart(2)}. ${label} ${item.inputs}`);
148
+ ctx.out.line(` → ${item.action} · contact ${item.contact}`);
149
+ });
150
+ if (data.unknownValue.length > TOP) ctx.out.line(` … +${data.unknownValue.length - TOP} more unknown-value items`);
151
+ ctx.out.line('');
152
+ }
153
+
154
+ ctx.out.line(' ' + bar());
155
+ ctx.out.line(' Ranked by money we can actually see (deal value, invoice amount due, est. value).');
156
+ if (data.mixedCurrencies) {
157
+ ctx.out.line(' ⚠ Mixed currencies detected — ranked by raw number, NOT converted. ₱1000 vs $100 is NOT comparable.');
158
+ }
159
+ ctx.out.line(' Read-only: I surface + point to the recipe. You act.\n');
160
+ });
161
+
162
+ return 0;
163
+ }
@@ -0,0 +1,117 @@
1
+ // commands/noshow.mjs — No-show recovery: surfaces who no-showed to re-book.
2
+ // Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
3
+ // READ-ONLY. Never messages, never books.
4
+ import { mapLimit } from '../lib/pool.mjs';
5
+ export const meta = {
6
+ name: 'noshow',
7
+ summary: 'No-show recovery — who to re-book',
8
+ flags: [
9
+ { name: '--days', type: 'int', default: 30, desc: 'lookback window' },
10
+ { name: '--top', type: 'int', default: 15, desc: 'max results' },
11
+ ],
12
+ readOnly: true,
13
+ };
14
+
15
+ // I-2 truncation cap: GHL's /calendars/events has no pagination cursor.
16
+ // If a calendar returns >= CAP events it is likely truncated (silently under-reports).
17
+ // Full fix = date-window splitting; tracked as follow-up. Cheap mitigation: warn + degrade.
18
+ const EVENTS_CAP = 100;
19
+
20
+ export async function collect(args, ctx) {
21
+ const DAYS = args.days ?? 30;
22
+ const TOP = args.top ?? 15;
23
+ const LOC = ctx.cfg.loc;
24
+ const NOW = ctx.now;
25
+ const START = NOW - DAYS * 24 * 60 * 60 * 1000;
26
+
27
+ const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
28
+ if (!cr.ok) {
29
+ ctx.out.warn(`can't see calendars → HTTP ${cr.code}`, { degraded: true });
30
+ return { location: LOC, calendars: 0, noshows: 0, shown: 0, list: [] };
31
+ }
32
+ const cals = cr.j.calendars || [];
33
+ const noshows = [];
34
+ let skippedCalendars = 0;
35
+ // Parallel fan-out, capped at 5 concurrent (GHL rate-limit-safe: 100 req/10s; 5 concurrent is well under).
36
+ // ONLY the independent per-calendar fetches are parallelized — pagination pages stay sequential.
37
+ const evResults = await mapLimit(cals, 5, async (cal) => {
38
+ const ev = await ctx.http.get('/calendars/events', {
39
+ query: { locationId: LOC, calendarId: cal.id, startTime: String(START), endTime: String(NOW) },
40
+ version: '2021-04-15',
41
+ });
42
+ return { cal, ev };
43
+ });
44
+ for (const { cal, ev } of evResults) {
45
+ if (!ev.ok) {
46
+ skippedCalendars++;
47
+ ctx.out.warn(`calendar "${cal.name || cal.id}" events unreadable (HTTP ${ev.code})`, { degraded: true });
48
+ continue;
49
+ }
50
+ const evList = ev.j.events || ev.j.appointments || [];
51
+ // I-2: truncation mitigation — no cursor available; warn if at cap
52
+ if (evList.length >= EVENTS_CAP) {
53
+ ctx.out.warn(`calendar "${cal.name || cal.id}" returned ${evList.length} events — may be truncated (no pagination cursor available); counts for this calendar may under-report`, { degraded: true });
54
+ }
55
+ for (const e of evList) {
56
+ const s = (e.appointmentStatus || e.status || '').toLowerCase();
57
+ if (s !== 'noshow' && s !== 'no-show' && s !== 'no_show') continue;
58
+ const t = Date.parse(e.startTime || e.startTimeISO || e.appointmentStartTime) || 0;
59
+ noshows.push({
60
+ name: e.title || e.contactName || '(unknown)',
61
+ contactId: e.contactId,
62
+ apptId: e.id,
63
+ when: t,
64
+ cal: cal.name,
65
+ calId: cal.id,
66
+ });
67
+ }
68
+ }
69
+ noshows.sort((a, b) => b.when - a.when);
70
+ const top = noshows.slice(0, TOP);
71
+
72
+ return {
73
+ location: LOC,
74
+ calendars: cals.length,
75
+ ...(skippedCalendars > 0 && { skippedCalendars }),
76
+ noshows: noshows.length,
77
+ shown: top.length,
78
+ list: top.map(n => ({
79
+ name: n.name,
80
+ contactId: n.contactId,
81
+ apptId: n.apptId,
82
+ when: new Date(n.when).toISOString(),
83
+ calendar: n.cal,
84
+ })),
85
+ };
86
+ }
87
+
88
+ export async function run(args, ctx) {
89
+ const data = await collect(args, ctx);
90
+ ctx.out.data(data);
91
+
92
+ const DAYS = args.days ?? 30;
93
+ const NOW = ctx.now;
94
+ const ago = (t) => {
95
+ const d = Math.floor((NOW - t) / 86400000);
96
+ return d >= 1 ? d + 'd' : Math.max(1, Math.floor((NOW - t) / 3600000)) + 'h';
97
+ };
98
+ const fmt = (t) =>
99
+ new Date(t).toLocaleString('en-US', { timeZone: 'Asia/Manila', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
100
+
101
+ ctx.out.card(() => {
102
+ ctx.out.line(`\n NO-SHOW RECOVERY — ${data.noshows} no-show(s) · last ${DAYS}d · ${data.calendars} calendars · loc ${data.location}`);
103
+ ctx.out.line(' ' + '─'.repeat(70));
104
+ if (!data.list.length) {
105
+ ctx.out.line(' No no-shows in window. ✅\n');
106
+ return;
107
+ }
108
+ data.list.forEach((n, i) => {
109
+ const ts = new Date(n.when).getTime();
110
+ ctx.out.line(` ${String(i + 1).padStart(2)}. ${(n.name || '(unknown)').slice(0, 24).padEnd(24)} ${fmt(ts).padEnd(20)} (${ago(ts)} ago)`);
111
+ ctx.out.line(` ${n.calendar} · contact ${n.contactId || '—'} · appt ${n.apptId}`);
112
+ });
113
+ ctx.out.line(' ' + '─'.repeat(70));
114
+ ctx.out.line(' → hand to ghl-conversations: draft a warm re-book message per contact; you approve each send (L2).\n');
115
+ });
116
+ return 0;
117
+ }
@@ -0,0 +1,147 @@
1
+ // commands/pipeline.mjs — Pipeline health: value by stage + stuck sweep.
2
+ // Trust-fix #1: LOC from ctx.cfg.loc.
3
+ // Trust-fix #2: opps paginate to completion.
4
+ // READ-ONLY.
5
+ import { paginate } from '../lib/paginate.mjs';
6
+
7
+ export const meta = {
8
+ name: 'pipeline',
9
+ summary: 'Pipeline health — value by stage + stuck deal sweep',
10
+ flags: [
11
+ { name: '--stuck-days', type: 'int', default: 7, desc: 'idle threshold in days' },
12
+ { name: '--top', type: 'int', default: 100, desc: 'max stuck deals to show' },
13
+ ],
14
+ readOnly: true,
15
+ };
16
+
17
+ // NOTE: GHL opportunity monetaryValue carries no currency field — it inherits pipeline config.
18
+ // Hardcoding ₱ here is a known GHL API limitation; no currency param available per-opportunity.
19
+ const money = (n) => !Number.isFinite(Number(n)) ? '—' : '₱' + Number(n || 0).toLocaleString('en-PH', { maximumFractionDigits: 0 });
20
+ const touchedAt = (o) =>
21
+ Date.parse(o.lastStatusChangeAt || o.lastStageChangeAt || o.updatedAt || o.dateUpdated || o.dateAdded || 0) || 0;
22
+
23
+ export async function collect(args, ctx) {
24
+ const STUCK_DAYS = args['stuck-days'] ?? 7;
25
+ const TOP = args.top ?? 100;
26
+ const LOC = ctx.cfg.loc;
27
+ const NOW = ctx.now;
28
+ const STUCK_MS = STUCK_DAYS * 24 * 60 * 60 * 1000;
29
+ const ago = (t) => {
30
+ const d = Math.floor((NOW - t) / 86400000);
31
+ return d >= 1 ? d + 'd' : Math.max(1, Math.floor((NOW - t) / 3600000)) + 'h';
32
+ };
33
+
34
+ // pipelines → stage id→name map
35
+ const p = await ctx.http.get('/opportunities/pipelines', { query: { locationId: LOC } });
36
+ if (!p.ok) {
37
+ ctx.out.warn(`can't see pipelines → HTTP ${p.code}`, { degraded: true });
38
+ return { location: LOC, totalValue: 0, openCount: 0, pipelines: [], stuck: [] };
39
+ }
40
+ const pipelines = p.j.pipelines || [];
41
+ const stageName = {}, pipeName = {}, stageOrder = {};
42
+ for (const pl of pipelines) {
43
+ pipeName[pl.id] = pl.name;
44
+ (pl.stages || []).forEach((s, i) => { stageName[s.id] = s.name; stageOrder[s.id] = i; });
45
+ }
46
+
47
+ // all open opps paginated to completion (trust-fix #2)
48
+ const opps = [];
49
+ let firstOppErr = null;
50
+ for await (const o of paginate({
51
+ fetchPage: async (page = 1) => {
52
+ const r = await ctx.http.get('/opportunities/search', {
53
+ query: { location_id: LOC, status: 'open', limit: 100, page },
54
+ });
55
+ if (!r.ok) return { _err: r.code, opportunities: [] };
56
+ return r.j;
57
+ },
58
+ getItems: (resp) => {
59
+ if (resp._err) { firstOppErr = resp._err; return []; }
60
+ return resp.opportunities || resp.data || [];
61
+ },
62
+ nextCursor: (resp, items, page = 1) => {
63
+ if (resp._err || items.length < 100) return null;
64
+ return page + 1;
65
+ },
66
+ maxPages: 20,
67
+ startCursor: 1,
68
+ })) {
69
+ opps.push(o);
70
+ }
71
+
72
+ if (firstOppErr && opps.length === 0) {
73
+ ctx.out.warn(`can't see opportunities → HTTP ${firstOppErr}`, { degraded: true });
74
+ return { location: LOC, totalValue: 0, openCount: 0, pipelines: [], stuck: [] };
75
+ }
76
+
77
+ // group by pipeline→stage
78
+ const byPipe = {};
79
+ let total = 0;
80
+ for (const o of opps) {
81
+ const pid = o.pipelineId, sid = o.pipelineStageId || o.stageId;
82
+ const val = Number(o.monetaryValue || o.monetary_value || 0) || 0;
83
+ total += val;
84
+ (byPipe[pid] ??= {})[sid] ??= { count: 0, value: 0 };
85
+ byPipe[pid][sid].count++;
86
+ byPipe[pid][sid].value += val;
87
+ }
88
+
89
+ // stuck = open, untouched >= STUCK_DAYS
90
+ const stuck = opps
91
+ .map(o => ({ o, t: touchedAt(o) }))
92
+ .filter(x => x.t > 0 && (NOW - x.t) >= STUCK_MS)
93
+ .sort((a, b) => a.t - b.t)
94
+ .slice(0, TOP);
95
+
96
+ return {
97
+ location: LOC,
98
+ totalValue: total,
99
+ openCount: opps.length,
100
+ pipelines: Object.entries(byPipe).map(([pid, stages]) => ({
101
+ pipeline: pipeName[pid] || pid,
102
+ stages: Object.entries(stages)
103
+ .map(([sid, v]) => ({ stage: stageName[sid] || sid, ...v }))
104
+ .sort((a, b) => (stageOrder[a.sid] || 0) - (stageOrder[b.sid] || 0)),
105
+ })),
106
+ stuck: stuck.map(x => ({
107
+ name: x.o.name,
108
+ value: x.o.monetaryValue,
109
+ stage: stageName[x.o.pipelineStageId] || '',
110
+ idle: ago(x.t),
111
+ oppId: x.o.id,
112
+ contactId: x.o.contactId,
113
+ })),
114
+ };
115
+ }
116
+
117
+ export async function run(args, ctx) {
118
+ const data = await collect(args, ctx);
119
+ ctx.out.data(data);
120
+
121
+ const STUCK_DAYS = args['stuck-days'] ?? 7;
122
+ const TOP = args.top ?? 100;
123
+ const money2 = (n) => '₱' + Number(n || 0).toLocaleString('en-PH', { maximumFractionDigits: 0 });
124
+
125
+ ctx.out.card(() => {
126
+ ctx.out.line(`\n PIPELINE HEALTH · ${money2(data.totalValue)} across ${data.openCount} open deal(s) · loc ${data.location}`);
127
+ for (const pl of data.pipelines) {
128
+ ctx.out.line(`\n ${pl.pipeline}`);
129
+ for (const s of pl.stages) {
130
+ ctx.out.line(` ${(s.stage || '').slice(0, 28).padEnd(28)} ${String(s.count).padStart(3)} deal ${money2(s.value).padStart(12)}`);
131
+ }
132
+ }
133
+ ctx.out.line(`\n STUCK — open + untouched ≥ ${STUCK_DAYS}d (oldest first, top ${TOP})`);
134
+ ctx.out.line(' ' + '─'.repeat(70));
135
+ if (!data.stuck.length) {
136
+ ctx.out.line(' Nothing stuck. Pipeline moving. ✅');
137
+ } else {
138
+ data.stuck.forEach((x, i) => {
139
+ ctx.out.line(` ${String(i + 1).padStart(2)}. ${(x.name || '(no name)').slice(0, 26).padEnd(26)} ${money2(x.value).padStart(11)} idle ${(x.idle || '?').padEnd(5)} ${x.stage}`);
140
+ ctx.out.line(` opp ${x.oppId} · contact ${x.contactId}`);
141
+ });
142
+ }
143
+ ctx.out.line(' ' + '─'.repeat(70));
144
+ ctx.out.line(' → nudge list = the stuck deals; I can move a stage / set lost-reason on your say-so (L2, one at a time).\n');
145
+ });
146
+ return 0;
147
+ }