sizmo 0.4.0 → 0.5.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # sizmo
2
2
 
3
- **Unofficial read-only GoHighLevel CLI for coaches and consultants.**
3
+ **Unofficial read-only GoHighLevel CLI.** Your GoHighLevel CRM — leads, bookings, pipeline, receivables, payments, and a money-ranked to-do list — from the terminal, in one command.
4
4
 
5
5
  > Not affiliated with, endorsed by, or supported by HighLevel. This is an independent open-source tool.
6
6
 
@@ -10,7 +10,16 @@
10
10
 
11
11
  Requires Node.js 20+.
12
12
 
13
- **Option A — clone + install (puts `sizmo` on your PATH):**
13
+ **Option A — npm (recommended):**
14
+
15
+ ```sh
16
+ npx sizmo brief # run with no install
17
+ # or install globally:
18
+ npm install -g sizmo
19
+ sizmo brief
20
+ ```
21
+
22
+ **Option B — clone + install (puts `sizmo` on your PATH from source):**
14
23
 
15
24
  ```sh
16
25
  git clone https://github.com/csalamida07-cyber/sizmo-ghl-cli
@@ -20,12 +29,6 @@ bash install.sh
20
29
 
21
30
  `install.sh` symlinks `bin/sizmo.mjs` into `~/.local/bin/sizmo`. Add `~/.local/bin` to `$PATH` if not already present (the script will warn you if needed).
22
31
 
23
- **Option B — run with no install, straight from GitHub:**
24
-
25
- ```sh
26
- npx github:csalamida07-cyber/sizmo-ghl-cli brief
27
- ```
28
-
29
32
  **Option C — clone + run directly:**
30
33
 
31
34
  ```sh
@@ -33,8 +36,6 @@ git clone https://github.com/csalamida07-cyber/sizmo-ghl-cli && cd sizmo-ghl-cli
33
36
  node bin/sizmo.mjs brief
34
37
  ```
35
38
 
36
- > `npx sizmo` (npm install) is coming once the package is published to npm.
37
-
38
39
  Then configure a profile:
39
40
 
40
41
  ```sh
@@ -43,6 +44,17 @@ echo "pit-yourtoken..." | sizmo config set --profile myclient --loc YOUR_LOCATIO
43
44
 
44
45
  PIT = Private Integration Token. Find it under GoHighLevel Settings > Integrations > Private Integrations. Never pass it as a command-line argument — always pipe it via stdin.
45
46
 
47
+ When creating the Private Integration, grant these scopes for the full `brief`:
48
+
49
+ ```
50
+ contacts.readonly · conversations.readonly · opportunities.readonly
51
+ calendars.readonly · invoices.readonly · payments/transactions.readonly
52
+ ```
53
+
54
+ Granting fewer is fine — missing scopes show as ⚠ in affected metrics rather than failing the whole command. Run `sizmo auth check` after setup to see a per-lane scope report.
55
+
56
+ **Auth: PIT vs MCP** — `sizmo` uses a Private Integration Token (PIT), not the GoHighLevel MCP server. See [`docs/how-to/auth-pit-vs-mcp.md`](docs/how-to/auth-pit-vs-mcp.md) for the comparison and when you'd want each.
57
+
46
58
  Verify auth:
47
59
 
48
60
  ```sh
@@ -66,6 +78,8 @@ Command list generated from `sizmo schema` (authoritative — pulled directly fr
66
78
  | `sizmo booked-not-paid` | Sessions with no invoice or payment — the money leak | `--days N` (default 30), `--top N` (default 15) |
67
79
  | `sizmo focus` | One ranked to-do queue by money at stake | `--top N` (default 15), `--stuck-days N` (default 7) |
68
80
  | `sizmo segment` | Find contacts by criteria — tag, phone, age, etc. | `--tag X`, `--without-tag X`, `--no-tags`, `--created-days N`, `--has-phone`, `--no-phone`, `--top N` (default 20) |
81
+ | `sizmo crm` | Query the local CRM model — counts, lists, staleness | `--all` (show all items) |
82
+ | `sizmo sync` | Refresh the local CRM model (pipelines, calendars, tags, fields, users, location) | `[entity]` (sync one) |
69
83
 
70
84
  ### Utility commands
71
85
 
@@ -107,6 +121,42 @@ Every command supports `--json`. The envelope shape is stable:
107
121
 
108
122
  `degraded: true` means at least one data source was blocked (scope or auth). Read `warnings`. A blocked source is not zero — treat it as unknown.
109
123
 
124
+ ## Your CRM model
125
+
126
+ `sizmo` caches the slow-changing structure of your CRM — pipelines + stages, calendars, tags, custom fields, users, and location — in a local file (`~/.config/sizmo/model/<locationId>.json`). Recipes read from this cache instead of re-fetching structure on every run.
127
+
128
+ **What it stores:** pipeline/stage names + IDs, calendar list, tag list, custom fields, user roster, and location info (timezone, currency, country). Structure only — no contacts, no conversations, no payments.
129
+
130
+ **Sync once, read fast.** The model is synced automatically on first use. After that, recipes use the cached copy. Run `sizmo sync` after you change your pipeline stages or add calendars:
131
+
132
+ ```sh
133
+ sizmo sync # full refresh (all 6 entities)
134
+ sizmo sync tags # refresh one entity only
135
+ ```
136
+
137
+ **Age is always shown.** `sizmo crm` shows how old each entity is. Stale entries (past TTL: 24h for pipelines/calendars/users/location; 12h for tags/fields) show a warning. The CLI never silently serves stale structure as current.
138
+
139
+ **Model never auto-syncs when stale.** It serves the cached data with a loud age banner. This avoids surprise network calls mid-recipe. Use `sizmo sync` or `--fresh` to force a refresh.
140
+
141
+ ```sh
142
+ sizmo crm # overview: counts + age per entity
143
+ sizmo crm pipelines # list pipelines + stages
144
+ sizmo crm calendars # list calendars
145
+ sizmo crm tags [--all] # list tags (truncated at 20 by default)
146
+ sizmo crm fields # list custom fields
147
+ sizmo crm users # list users
148
+ sizmo crm location # timezone / currency / country
149
+ sizmo crm pipelines --json # machine output with _meta.source/syncedAt/ageMs/stale
150
+ ```
151
+
152
+ The JSON `_meta` block in every `crm` response lets agents branch on staleness without parsing prose:
153
+
154
+ ```json
155
+ "_meta": { "source": "cache", "syncedAt": 1718000000000, "ageMs": 3600000, "stale": false, "offline": false }
156
+ ```
157
+
158
+ **Scope requirements** for a full sync: `opportunities.readonly`, `calendars.readonly`, `locations/tags.readonly`, `locations/customFields.readonly`, `users.readonly`, `locations.readonly`. A 401/403 on one entity marks it blocked; the rest still store. `sizmo crm` shows `✖ needs <scope>` for blocked entities.
159
+
110
160
  ## Read-only + safety promise
111
161
 
112
162
  - **Never writes to GoHighLevel.** No contacts created, no messages sent, no invoices issued, no payments charged.
@@ -149,4 +199,4 @@ MIT. See LICENSE.
149
199
 
150
200
  ---
151
201
 
152
- Built by Sizmo — productized GHL systems for coaches & consultants. Unofficial; not affiliated with HighLevel.
202
+ Built by [Sizmo](https://github.com/csalamida07-cyber/sizmo-ghl-cli) — GHL CRM systems & automation. Unofficial; not affiliated with HighLevel.
package/SKILL.md CHANGED
@@ -28,4 +28,4 @@ Read-only GoHighLevel ops. Every command takes `--json` (stable envelope: `{sche
28
28
  - No location resolved → exit 3. Pass `--profile` or set `GHL_LOCATION_ID`; there is no default location.
29
29
 
30
30
  ---
31
- Built by Sizmo — productized GHL systems for coaches & consultants. Unofficial; not affiliated with HighLevel.
31
+ Built by Sizmo — GHL CRM systems & automation. Unofficial; not affiliated with HighLevel.
@@ -0,0 +1,258 @@
1
+ // commands/crm.mjs — query surface for the local CRM model.
2
+ // Overview counts + per-entity lists. Honest staleness on every read.
3
+ // Missing model → auto-sync once (first run). Stale → serve + banner, no auto-sync.
4
+ // READ-ONLY. No writes to GoHighLevel.
5
+ import { loadModel, syncModel, isStale, ageMs, ENTITY_SPECS, DEFAULT_MODEL_DIR } from '../lib/model.mjs';
6
+
7
+ export const meta = {
8
+ name: 'crm',
9
+ summary: 'Query the local CRM model — counts, lists, staleness',
10
+ flags: [
11
+ { name: '--all', type: 'bool', desc: 'show all items (overrides high-cardinality truncation)' },
12
+ ],
13
+ readOnly: true,
14
+ };
15
+
16
+ const TRUNCATE_ABOVE = 20; // default max items for high-cardinality entities (tags, fields)
17
+
18
+ // Alias map for subcommands
19
+ const ALIAS = { fields: 'customFields' };
20
+ const VALID_SUBS = ['pipelines', 'calendars', 'tags', 'fields', 'users', 'location'];
21
+
22
+ export async function run(args, ctx) {
23
+ const dir = ctx._modelDir ?? DEFAULT_MODEL_DIR;
24
+ const loc = ctx.cfg.loc;
25
+ const nowMs = typeof ctx.now === 'function' ? ctx.now() : ctx.now;
26
+ const showAll = !!(args.all || args['--all']);
27
+
28
+ // Sub-command from positional args
29
+ const sub = args._?.[0] || null;
30
+
31
+ // 1. Load model (auto-sync if missing)
32
+ let model = loadModel(loc, dir);
33
+ if (!model) {
34
+ // First run — auto-sync. syncModel throws if cold+offline.
35
+ ctx.out.warn('model not found — running first-time sync...');
36
+ try {
37
+ model = await syncModel({ http: ctx.http, loc, dir, now: typeof ctx.now === 'function' ? ctx.now : () => ctx.now });
38
+ } catch (e) {
39
+ if (e.offline) {
40
+ ctx.out.warn("⚠ OFFLINE — can't reach GoHighLevel — check your connection; run `sizmo sync` when online");
41
+ return 1;
42
+ }
43
+ throw e;
44
+ }
45
+ }
46
+
47
+ // 2. Build overall model meta
48
+ const modelAgeMs = nowMs - model.syncedAt;
49
+ // Determine overall staleness: any entity past its TTL
50
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
51
+ let anyStale = false;
52
+ for (const [name, ent] of Object.entries(model.entities)) {
53
+ if (!ent.blocked && !ent.networkError && specMap[name] && isStale(ent, nowMs, specMap[name].ttlMs)) {
54
+ anyStale = true;
55
+ }
56
+ }
57
+
58
+ // Determine offline: model was last sync'd while offline, OR the model itself has the offline flag.
59
+ // Also detect if a model exists but a refresh just failed (model.offline = true from the last sync).
60
+ const offline = !!(model.offline);
61
+ const meta = { source: 'cache', syncedAt: model.syncedAt, ageMs: modelAgeMs, stale: anyStale, offline };
62
+
63
+ // 3. Warn if offline (showing stale/cached data) or just stale
64
+ if (offline) {
65
+ const cacheAge = fmtAge(modelAgeMs);
66
+ ctx.out.warn(`⚠ OFFLINE — showing cache from ${new Date(model.syncedAt).toISOString()} (${cacheAge} old) — run \`sizmo sync\` when online`);
67
+ } else if (anyStale) {
68
+ ctx.out.warn(`⚠ model is stale (${fmtAge(modelAgeMs)} old) — run sizmo sync to refresh`);
69
+ }
70
+
71
+ // 4. Dispatch sub-command or overview
72
+ if (!sub) {
73
+ return overview(model, meta, nowMs, specMap, ctx);
74
+ }
75
+
76
+ const resolved = ALIAS[sub] ?? sub;
77
+ if (!VALID_SUBS.includes(sub) && !VALID_SUBS.includes(resolved)) {
78
+ ctx.out.warn(`unknown crm subcommand "${sub}" — valid: ${VALID_SUBS.join(', ')}`);
79
+ return 1;
80
+ }
81
+
82
+ if (resolved === 'location') return locationCmd(model, meta, ctx);
83
+ return entityList(resolved, model, meta, nowMs, specMap, showAll, ctx);
84
+ }
85
+
86
+ // ── overview ──────────────────────────────────────────────────────────────────
87
+
88
+ function overview(model, modelMeta, nowMs, specMap, ctx) {
89
+ const counts = {};
90
+ const blocked = {};
91
+ const networkErrors = {};
92
+ for (const spec of ENTITY_SPECS) {
93
+ const ent = model.entities[spec.name];
94
+ if (!ent) { counts[spec.name] = 0; continue; }
95
+ if (ent.networkError) {
96
+ counts[spec.name] = null;
97
+ networkErrors[spec.name] = ent.error ?? 'network error';
98
+ } else if (ent.blocked) {
99
+ counts[spec.name] = null;
100
+ blocked[spec.name] = ent.scope;
101
+ } else if (spec.name === 'location') {
102
+ counts[spec.name] = ent.item ? 1 : 0;
103
+ } else {
104
+ counts[spec.name] = Array.isArray(ent.items) ? ent.items.length : 0;
105
+ }
106
+ }
107
+
108
+ // Build human-friendly output structure
109
+ const data = {
110
+ pipelines: counts.pipelines,
111
+ calendars: counts.calendars,
112
+ tags: counts.tags,
113
+ customFields: counts.customFields,
114
+ users: counts.users,
115
+ location: counts.location,
116
+ _meta: modelMeta,
117
+ };
118
+ // Add blocked/networkError flags so agents can branch
119
+ for (const [name, scope] of Object.entries(blocked)) {
120
+ data[`${name}Blocked`] = true;
121
+ data[`${name}Scope`] = scope;
122
+ }
123
+ for (const [name] of Object.entries(networkErrors)) {
124
+ data[`${name}NetworkError`] = true;
125
+ }
126
+
127
+ ctx.out.data(data);
128
+
129
+ ctx.out.card(() => {
130
+ const age = fmtAge(modelMeta.ageMs);
131
+ const staleNote = modelMeta.offline ? ` ⚠ OFFLINE` : (modelMeta.stale ? ` ⚠ STALE — run sizmo sync` : '');
132
+ ctx.out.line(`\n CRM MODEL · loc ${model.locationId} · synced ${age}${staleNote}`);
133
+ ctx.out.line(' ' + '─'.repeat(50));
134
+ for (const spec of ENTITY_SPECS) {
135
+ if (spec.name === 'location') continue; // shown separately
136
+ const ent = model.entities[spec.name];
137
+ if (!ent) { ctx.out.line(` ${spec.name.padEnd(16)} 0`); continue; }
138
+ if (ent.networkError) {
139
+ ctx.out.line(` ${spec.name.padEnd(16)} ⚠ couldn't reach GHL`);
140
+ } else if (ent.blocked) {
141
+ ctx.out.line(` ${spec.name.padEnd(16)} ✖ needs ${ent.scope}`);
142
+ } else {
143
+ const count = Array.isArray(ent.items) ? ent.items.length : 0;
144
+ const entAge = ageMs(ent, nowMs);
145
+ const entStale = isStale(ent, nowMs, specMap[spec.name]?.ttlMs ?? Infinity);
146
+ const ageNote = entAge !== null ? ` · ${fmtAge(entAge)}${entStale ? ' ⚠' : ''}` : '';
147
+ ctx.out.line(` ${spec.name.padEnd(16)} ${count}${ageNote}`);
148
+ }
149
+ }
150
+ // Location line
151
+ const locEnt = model.entities.location;
152
+ if (locEnt && !locEnt.blocked && locEnt.item) {
153
+ const loc = locEnt.item;
154
+ const cur = loc.business?.currency || loc.currency || 'PHP';
155
+ ctx.out.line(` ${'location'.padEnd(16)} ${loc.name || model.locationId} · ${cur} · ${loc.timezone || ''}`);
156
+ }
157
+ ctx.out.line(' ' + '─'.repeat(50));
158
+ ctx.out.line(' sizmo crm <pipelines|calendars|tags|fields|users|location> for details\n');
159
+ });
160
+
161
+ return 0;
162
+ }
163
+
164
+ // ── entity list ───────────────────────────────────────────────────────────────
165
+
166
+ function entityList(entityName, model, modelMeta, nowMs, specMap, showAll, ctx) {
167
+ const ent = model.entities[entityName];
168
+ const spec = specMap[entityName];
169
+
170
+ if (ent?.networkError) {
171
+ ctx.out.warn(`⚠ ${entityName} — couldn't reach GHL during last sync`);
172
+ ctx.out.data({ entity: entityName, networkError: true, _meta: modelMeta });
173
+ return 1;
174
+ }
175
+
176
+ if (!ent || ent.blocked) {
177
+ const scope = ent?.scope ?? 'unknown';
178
+ ctx.out.warn(`✖ ${entityName} blocked — needs ${scope}`);
179
+ ctx.out.data({ entity: entityName, blocked: true, scope, _meta: modelMeta });
180
+ return 1;
181
+ }
182
+
183
+ const items = Array.isArray(ent.items) ? ent.items : [];
184
+ const entAge = ageMs(ent, nowMs);
185
+ const stale = spec ? isStale(ent, nowMs, spec.ttlMs) : false;
186
+ const entMeta = { ...modelMeta, entityFetchedAt: ent.fetchedAt, entityAgeMs: entAge, entityStale: stale };
187
+
188
+ // High-cardinality truncation (tags, customFields)
189
+ const highCard = entityName === 'tags' || entityName === 'customFields';
190
+ const shown = (highCard && !showAll) ? items.slice(0, TRUNCATE_ABOVE) : items;
191
+ const truncated = shown.length < items.length;
192
+
193
+ ctx.out.data({ entity: entityName, items: shown, total: items.length, truncated, _meta: entMeta });
194
+
195
+ ctx.out.card(() => {
196
+ const ageNote = entAge !== null ? `synced ${fmtAge(entAge)} ago` : '';
197
+ const staleNote = stale ? ' ⚠ STALE' : '';
198
+ ctx.out.line(`\n ${entityName.toUpperCase()} · ${items.length} item(s) · ${ageNote}${staleNote}`);
199
+ ctx.out.line(' ' + '─'.repeat(50));
200
+ if (entityName === 'pipelines') {
201
+ for (const pl of shown) {
202
+ ctx.out.line(` ${(pl.name || pl.id).slice(0, 40)}`);
203
+ for (const s of (pl.stages || [])) {
204
+ ctx.out.line(` [${String(s.position ?? '').padStart(2)}] ${(s.name || s.id).slice(0, 36)}`);
205
+ }
206
+ }
207
+ } else if (entityName === 'users') {
208
+ for (const u of shown) {
209
+ const name = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.name || u.id;
210
+ ctx.out.line(` ${name.slice(0, 30).padEnd(30)} ${(u.email || '').slice(0, 36)}`);
211
+ }
212
+ } else {
213
+ for (const item of shown) {
214
+ const label = item.name || item.id;
215
+ const extra = item.fieldKey ? ` key: ${item.fieldKey}` : (item.calendarType ? ` ${item.calendarType}` : '');
216
+ ctx.out.line(` ${label.slice(0, 40)}${extra}`);
217
+ }
218
+ }
219
+ if (truncated) ctx.out.line(` … ${items.length - shown.length} more — --all to show all`);
220
+ ctx.out.line(' ' + '─'.repeat(50) + '\n');
221
+ });
222
+
223
+ return 0;
224
+ }
225
+
226
+ // ── location subcommand ───────────────────────────────────────────────────────
227
+
228
+ function locationCmd(model, modelMeta, ctx) {
229
+ const ent = model.entities.location;
230
+ if (!ent || ent.blocked) {
231
+ const scope = ent?.scope ?? 'locations.readonly';
232
+ ctx.out.warn(`✖ location blocked — needs ${scope}`);
233
+ ctx.out.data({ blocked: true, scope, _meta: modelMeta });
234
+ return 1;
235
+ }
236
+ const item = ent.item ?? {};
237
+ ctx.out.data({ item, location: item, _meta: modelMeta });
238
+ ctx.out.card(() => {
239
+ ctx.out.line(`\n LOCATION · ${item.name || model.locationId}`);
240
+ ctx.out.line(' ' + '─'.repeat(40));
241
+ ctx.out.line(` id ${item.id || model.locationId}`);
242
+ ctx.out.line(` timezone ${item.timezone || '—'}`);
243
+ ctx.out.line(` currency ${item.business?.currency || item.currency || '—'}`);
244
+ ctx.out.line(` country ${item.country || '—'}`);
245
+ ctx.out.line(' ' + '─'.repeat(40) + '\n');
246
+ });
247
+ return 0;
248
+ }
249
+
250
+ // ── helpers ───────────────────────────────────────────────────────────────────
251
+
252
+ function fmtAge(ms) {
253
+ if (ms == null || ms < 0) return '?';
254
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
255
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
256
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`;
257
+ return `${Math.round(ms / 86_400_000)}d`;
258
+ }
@@ -1,7 +1,10 @@
1
1
  // commands/noshow.mjs — No-show recovery: surfaces who no-showed to re-book.
2
2
  // Trust-fix #1: LOC from ctx.cfg.loc (no baked default).
3
+ // v0.5.0: calendar list from CRM model (no per-run /calendars/ re-fetch); events still live.
4
+ // v0.6.0 (C2): modelMeta emitted in JSON envelope; staleness note in TTY.
3
5
  // READ-ONLY. Never messages, never books.
4
6
  import { mapLimit } from '../lib/pool.mjs';
7
+ import { ENTITY_SPECS } from '../lib/model.mjs';
5
8
  export const meta = {
6
9
  name: 'noshow',
7
10
  summary: 'No-show recovery — who to re-book',
@@ -24,12 +27,39 @@ export async function collect(args, ctx) {
24
27
  const NOW = ctx.now;
25
28
  const START = NOW - DAYS * 24 * 60 * 60 * 1000;
26
29
 
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: [] };
30
+ // Get calendar list from the CRM model if available; fall back to live fetch.
31
+ let cals = null;
32
+ let modelLoaded = null;
33
+ let modelMeta = null;
34
+ if (ctx.ensureModel) {
35
+ try {
36
+ modelLoaded = await ctx.ensureModel();
37
+ if (modelLoaded?.entities?.calendars && !modelLoaded.entities.calendars.blocked && !modelLoaded.entities.calendars.networkError) {
38
+ cals = modelLoaded.entities.calendars.items ?? [];
39
+ }
40
+ } catch { /* fall through to live fetch */ }
41
+ }
42
+ // Build modelMeta for the JSON envelope (C2)
43
+ if (modelLoaded) {
44
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
45
+ const calEnt = modelLoaded.entities?.calendars;
46
+ const calSpec = specMap.calendars;
47
+ const calStale = calEnt && calSpec ? (NOW - (calEnt.fetchedAt ?? 0)) > calSpec.ttlMs : false;
48
+ modelMeta = {
49
+ syncedAt: modelLoaded.syncedAt,
50
+ ageMs: NOW - modelLoaded.syncedAt,
51
+ stale: calStale,
52
+ offline: !!(modelLoaded.offline),
53
+ };
54
+ }
55
+ if (cals === null) {
56
+ const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
57
+ if (!cr.ok) {
58
+ ctx.out.warn(`can't see calendars → HTTP ${cr.code}`, { degraded: true });
59
+ return { location: LOC, calendars: 0, noshows: 0, shown: 0, list: [], ...(modelMeta ? { modelMeta } : {}) };
60
+ }
61
+ cals = cr.j.calendars || [];
31
62
  }
32
- const cals = cr.j.calendars || [];
33
63
  const noshows = [];
34
64
  let skippedCalendars = 0;
35
65
  // Parallel fan-out, capped at 5 concurrent (GHL rate-limit-safe: 100 req/10s; 5 concurrent is well under).
@@ -82,6 +112,7 @@ export async function collect(args, ctx) {
82
112
  when: new Date(n.when).toISOString(),
83
113
  calendar: n.cal,
84
114
  })),
115
+ ...(modelMeta ? { modelMeta } : {}),
85
116
  };
86
117
  }
87
118
 
@@ -100,6 +131,16 @@ export async function run(args, ctx) {
100
131
 
101
132
  ctx.out.card(() => {
102
133
  ctx.out.line(`\n NO-SHOW RECOVERY — ${data.noshows} no-show(s) · last ${DAYS}d · ${data.calendars} calendars · loc ${data.location}`);
134
+ // C2: staleness note when model is old/offline
135
+ if (data.modelMeta) {
136
+ const mm = data.modelMeta;
137
+ if (mm.offline) {
138
+ ctx.out.line(` · CRM model OFFLINE — calendar list from cache`);
139
+ } else if (mm.stale) {
140
+ const ageD = Math.round(mm.ageMs / 86400000);
141
+ ctx.out.line(` · CRM model ${ageD}d old — run sizmo sync`);
142
+ }
143
+ }
103
144
  ctx.out.line(' ' + '─'.repeat(70));
104
145
  if (!data.list.length) {
105
146
  ctx.out.line(' No no-shows in window. ✅\n');
@@ -1,8 +1,12 @@
1
1
  // commands/pipeline.mjs — Pipeline health: value by stage + stuck sweep.
2
2
  // Trust-fix #1: LOC from ctx.cfg.loc.
3
3
  // Trust-fix #2: opps paginate to completion.
4
+ // v0.5.0: stage/pipeline names sourced from ctx CRM model (no per-run structure re-fetch).
5
+ // v0.6.0 (C2): names resolved via ctx.resolve (never fabricated); modelMeta emitted with staleness signal.
6
+ // I1 fix: stage sort uses model position (not undefined .sid).
4
7
  // READ-ONLY.
5
8
  import { paginate } from '../lib/paginate.mjs';
9
+ import { ENTITY_SPECS } from '../lib/model.mjs';
6
10
 
7
11
  export const meta = {
8
12
  name: 'pipeline',
@@ -31,19 +35,75 @@ export async function collect(args, ctx) {
31
35
  return d >= 1 ? d + 'd' : Math.max(1, Math.floor((NOW - t) / 3600000)) + 'h';
32
36
  };
33
37
 
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: [] };
38
+ // Build stage/pipeline name resolution from the CRM model (no per-run structure re-fetch).
39
+ // Falls back to a live fetch only when model is genuinely unavailable.
40
+ // C2: names go through ctx.resolve when available — miss → '<unknown:id — run sizmo sync>'
41
+ // I1: stagePosition map keyed by stage-id carries model position (not a discarded .sid).
42
+
43
+ let modelLoaded = null; // the raw model blob (for modelMeta)
44
+ let resolver = null; // ctx.resolve (makeResolver instance)
45
+ const stagePosition = {}; // sid → model position (for sort)
46
+ const stageName = {}, pipeName = {}; // fallback maps (live-fetch path)
47
+
48
+ // Try model first (via ctx.ensureModel / ctx.resolve)
49
+ const usingModelPath = !!(ctx.ensureModel || ctx.resolve);
50
+ if (usingModelPath) {
51
+ try {
52
+ if (ctx.ensureModel) modelLoaded = await ctx.ensureModel();
53
+ resolver = ctx.resolve ?? null;
54
+ // Build stagePosition from model for sort (I1 fix)
55
+ if (modelLoaded?.entities?.pipelines && !modelLoaded.entities.pipelines.blocked && !modelLoaded.entities.pipelines.networkError) {
56
+ for (const pl of (modelLoaded.entities.pipelines.items ?? [])) {
57
+ for (const s of (pl.stages || [])) {
58
+ stagePosition[s.id] = s.position ?? 0;
59
+ }
60
+ }
61
+ }
62
+ } catch { /* fall through to live fetch */ }
39
63
  }
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; });
64
+
65
+ // modelMeta for staleness signal (C2)
66
+ const specMap = Object.fromEntries(ENTITY_SPECS.map(s => [s.name, s]));
67
+ let modelMeta = null;
68
+ if (modelLoaded) {
69
+ const plEnt = modelLoaded.entities?.pipelines;
70
+ const plSpec = specMap.pipelines;
71
+ const plFetchedAt = plEnt?.fetchedAt ?? null;
72
+ const plAgeMs = plFetchedAt != null ? NOW - plFetchedAt : null;
73
+ const plStale = plEnt && plSpec ? (NOW - (plEnt.fetchedAt ?? 0)) > plSpec.ttlMs : false;
74
+ modelMeta = {
75
+ syncedAt: modelLoaded.syncedAt,
76
+ ageMs: NOW - modelLoaded.syncedAt,
77
+ stale: plStale,
78
+ offline: !!(modelLoaded.offline),
79
+ };
80
+ }
81
+
82
+ if (!resolver) {
83
+ // Fallback: live fetch (model genuinely unavailable)
84
+ const p = await ctx.http.get('/opportunities/pipelines', { query: { locationId: LOC } });
85
+ if (!p.ok) {
86
+ ctx.out.warn(`can't see pipelines → HTTP ${p.code}`, { degraded: true });
87
+ return { location: LOC, totalValue: 0, openCount: 0, pipelines: [], stuck: [], modelMeta };
88
+ }
89
+ const pipelines = p.j.pipelines || [];
90
+ for (const pl of pipelines) {
91
+ pipeName[pl.id] = pl.name;
92
+ (pl.stages || []).forEach((s) => { stageName[s.id] = s.name; stagePosition[s.id] = s.position ?? 0; });
93
+ }
45
94
  }
46
95
 
96
+ // Helper: resolve pipeline name (via resolver or fallback map)
97
+ const resolvePipeName = (pid) => {
98
+ if (resolver) return resolver.label('pipeline', pid);
99
+ return pipeName[pid] || pid;
100
+ };
101
+ // Helper: resolve stage name (via resolver or fallback map)
102
+ const resolveStageName = (sid) => {
103
+ if (resolver) return resolver.label('stage', sid);
104
+ return stageName[sid] || sid;
105
+ };
106
+
47
107
  // all open opps paginated to completion (trust-fix #2)
48
108
  const opps = [];
49
109
  let firstOppErr = null;
@@ -98,19 +158,22 @@ export async function collect(args, ctx) {
98
158
  totalValue: total,
99
159
  openCount: opps.length,
100
160
  pipelines: Object.entries(byPipe).map(([pid, stages]) => ({
101
- pipeline: pipeName[pid] || pid,
161
+ pipeline: resolvePipeName(pid),
162
+ // I1 fix: carry sid onto mapped object; sort by model stagePosition (never undefined)
102
163
  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)),
164
+ .map(([sid, v]) => ({ sid, stage: resolveStageName(sid), position: stagePosition[sid] ?? Infinity, ...v }))
165
+ .sort((a, b) => a.position - b.position)
166
+ .map(({ sid: _sid, position: _pos, ...rest }) => rest), // strip internal keys from output
105
167
  })),
106
168
  stuck: stuck.map(x => ({
107
169
  name: x.o.name,
108
170
  value: x.o.monetaryValue,
109
- stage: stageName[x.o.pipelineStageId] || '',
171
+ stage: resolveStageName(x.o.pipelineStageId || x.o.stageId || ''),
110
172
  idle: ago(x.t),
111
173
  oppId: x.o.id,
112
174
  contactId: x.o.contactId,
113
175
  })),
176
+ ...(modelMeta ? { modelMeta } : {}),
114
177
  };
115
178
  }
116
179
 
@@ -124,6 +187,16 @@ export async function run(args, ctx) {
124
187
 
125
188
  ctx.out.card(() => {
126
189
  ctx.out.line(`\n PIPELINE HEALTH · ${money2(data.totalValue)} across ${data.openCount} open deal(s) · loc ${data.location}`);
190
+ // C2: staleness note when model is old/offline
191
+ if (data.modelMeta) {
192
+ const mm = data.modelMeta;
193
+ if (mm.offline) {
194
+ ctx.out.line(` · CRM model OFFLINE — stage names from cache`);
195
+ } else if (mm.stale) {
196
+ const ageD = Math.round(mm.ageMs / 86400000);
197
+ ctx.out.line(` · CRM model ${ageD}d old — run sizmo sync`);
198
+ }
199
+ }
127
200
  for (const pl of data.pipelines) {
128
201
  ctx.out.line(`\n ${pl.pipeline}`);
129
202
  for (const s of pl.stages) {