@zeyos/client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +21 -4
  3. package/agents/README.md +16 -6
  4. package/agents/shared/zeyos-agent-operating-guide.md +112 -0
  5. package/agents/shared/zeyos-query-patterns.md +12 -1
  6. package/agents/zeyos/SKILL.md +95 -0
  7. package/agents/zeyos-account-intelligence/SKILL.md +1 -1
  8. package/agents/zeyos-account-intelligence/references/workflows.md +7 -0
  9. package/agents/zeyos-billing-insights/SKILL.md +6 -1
  10. package/agents/zeyos-billing-insights/references/workflows.md +32 -3
  11. package/agents/zeyos-campaign-and-outreach/SKILL.md +1 -1
  12. package/agents/zeyos-campaign-and-outreach/references/workflows.md +8 -0
  13. package/agents/zeyos-collaboration-and-activity/SKILL.md +1 -1
  14. package/agents/zeyos-collaboration-and-activity/references/workflows.md +9 -0
  15. package/agents/zeyos-collections-and-dunning/SKILL.md +1 -1
  16. package/agents/zeyos-commerce-and-inventory/SKILL.md +1 -1
  17. package/agents/zeyos-commerce-and-inventory/references/workflows.md +7 -0
  18. package/agents/zeyos-mail-operations/SKILL.md +1 -1
  19. package/agents/zeyos-notes-and-sops/SKILL.md +1 -1
  20. package/agents/zeyos-platform-and-schema/SKILL.md +1 -1
  21. package/agents/zeyos-platform-and-schema/references/workflows.md +8 -0
  22. package/agents/zeyos-work-management/SKILL.md +1 -1
  23. package/docs/02-javascript-client/01-getting-started.md +15 -0
  24. package/docs/03-cli/01-getting-started.md +10 -2
  25. package/docs/03-cli/02-commands.md +58 -6
  26. package/docs/04-agent-workflows/01-agent-quickstart.md +2 -1
  27. package/docs/intro.md +1 -1
  28. package/package.json +9 -3
  29. package/samples/missioncontrol/README.md +106 -0
  30. package/samples/missioncontrol/fetch-data.mjs +341 -0
  31. package/samples/missioncontrol/index.html +419 -0
  32. package/src/runtime/client.js +27 -0
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mission Control — data fetcher
4
+ * ──────────────────────────────
5
+ * Pulls team-performance data from a live ZeyOS instance using the
6
+ * `@zeyos/client` library and the credentials you already created with
7
+ * `zeyos login` (read from .zeyos/auth.json or ~/.config/zeyos/credentials.json).
8
+ *
9
+ * It aggregates tickets (velocity) and `actionsteps` (time entries) into the
10
+ * metrics the dashboard needs and writes `data.js` (a `window.MISSION_DATA = …`
11
+ * assignment) so `index.html` can be opened straight from disk — no server, no
12
+ * CORS, no token pasting.
13
+ *
14
+ * Usage:
15
+ * node samples/missioncontrol/fetch-data.mjs # 90-day window
16
+ * node samples/missioncontrol/fetch-data.mjs --days 180
17
+ *
18
+ * Read-only: this script only issues list/count queries. It never writes to ZeyOS.
19
+ */
20
+
21
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ import { createZeyosClient, MemoryTokenStore, normalizeListResult } from '../../src/index.js';
27
+
28
+ const __dir = dirname(fileURLToPath(import.meta.url));
29
+ const DAY = 86400;
30
+
31
+ // ── Tunables ────────────────────────────────────────────────────────────────
32
+ const WINDOW_DAYS = parseInt(argValue('--days') ?? '90', 10); // primary window
33
+ const TREND_WEEKS = 13; // velocity / digest trend
34
+ const CONTRIB_WEEKS = 53; // contribution graph span
35
+ const STALE_DAYS = 7; // "last activity" warning threshold
36
+ const TOP_TYPES = 6; // distinct time-entry types to chart; rest → "Other"
37
+
38
+ // ── Status vocabulary ─────────────────────────────────────────────────────────
39
+ const CLOSED_STATUSES = [9, 11]; // tickets: COMPLETED + BOOKED
40
+ const OPEN_STATUSES = [0, 1, 2, 4, 6, 7]; // tickets/tasks: in-flight backlog
41
+ const TIME_STATUSES = [1, 3]; // actionsteps: COMPLETED + BOOKED (booked time)
42
+
43
+ // ── Credentials ───────────────────────────────────────────────────────────────
44
+ const LOCAL_FILE = '.zeyos/auth.json';
45
+ const GLOBAL_FILE = join(homedir(), '.config', 'zeyos', 'credentials.json');
46
+
47
+ function findCredentials() {
48
+ let dir = process.cwd();
49
+ for (;;) {
50
+ const candidate = join(dir, LOCAL_FILE);
51
+ if (existsSync(candidate)) return candidate;
52
+ const parent = dirname(dir);
53
+ if (parent === dir) break;
54
+ dir = parent;
55
+ }
56
+ return existsSync(GLOBAL_FILE) ? GLOBAL_FILE : null;
57
+ }
58
+
59
+ const credPath = findCredentials();
60
+ if (!credPath) { console.error('No ZeyOS credentials found. Run `zeyos login` first.'); process.exit(1); }
61
+ const cred = JSON.parse(readFileSync(credPath, 'utf8'));
62
+ if (!cred.baseUrl || !cred.accessToken) {
63
+ console.error(`Credentials at ${credPath} look incomplete. Run \`zeyos login\` again.`); process.exit(1);
64
+ }
65
+
66
+ const tokenStore = new MemoryTokenStore({
67
+ accessToken: cred.accessToken, refreshToken: cred.refreshToken,
68
+ expiresAt: cred.expiresAt, refreshTokenExpiresAt: cred.refreshTokenExpiresAt,
69
+ });
70
+ const client = createZeyosClient({
71
+ platform: cred.baseUrl,
72
+ auth: { mode: 'oauth', oauth: { clientId: cred.clientId, clientSecret: cred.clientSecret, tokenStore, autoRefresh: true } },
73
+ });
74
+
75
+ async function persistTokens() {
76
+ try {
77
+ const ts = await tokenStore.get();
78
+ if (ts?.accessToken && ts.accessToken !== cred.accessToken) {
79
+ writeFileSync(credPath, JSON.stringify({ ...cred, ...ts }, null, 2) + '\n', { mode: 0o600 });
80
+ }
81
+ } catch { /* non-critical */ }
82
+ }
83
+
84
+ // ── Query helpers ───────────────────────────────────────────────────────────
85
+ async function listAll(op, body) {
86
+ return normalizeListResult(await client.api[op]({ limit: 10000, ...body })).data;
87
+ }
88
+ /** Page through a large collection (ZeyOS caps a single page at 10000). */
89
+ async function listPaged(op, body, cap = 250000) {
90
+ const out = []; const limit = 10000;
91
+ for (let offset = 0; ; offset += limit) {
92
+ const page = normalizeListResult(await client.api[op]({ ...body, limit, offset })).data;
93
+ out.push(...page);
94
+ if (page.length < limit || out.length >= cap) break;
95
+ }
96
+ return out;
97
+ }
98
+
99
+ // ── Aggregation utilities ─────────────────────────────────────────────────────
100
+ const sum = (xs) => xs.reduce((a, b) => a + b, 0);
101
+ const mean = (xs) => (xs.length ? sum(xs) / xs.length : 0);
102
+ const median = (xs) => { if (!xs.length) return 0; const s = [...xs].sort((a, b) => a - b); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
103
+ const percentile = (xs, p) => { if (!xs.length) return 0; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; };
104
+ const round1 = (n) => Math.round(n * 10) / 10;
105
+ const weekLabel = (ts) => { const d = new Date(ts * 1000); return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; };
106
+
107
+ // ── Main ──────────────────────────────────────────────────────────────────────
108
+ async function main() {
109
+ const REAL_NOW = Math.floor(Date.now() / 1000);
110
+ console.error(`⚡️ Mission Control — fetching from ${cred.baseUrl}`);
111
+ console.error(` window: last ${WINDOW_DAYS} days · contribution: ${CONTRIB_WEEKS} weeks\n`);
112
+
113
+ // 1) Active roster
114
+ const users = await listAll('listUsers', { fields: ['ID', 'name', 'email'], filters: { activity: 0 } });
115
+ const userById = new Map(users.map((u) => [u.ID, u]));
116
+ console.error(`· users (active): ${users.length}`);
117
+
118
+ // 2) Group membership → department/location facet (user extdata isn't API-readable).
119
+ const groups = await listAll('listGroups', { fields: ['ID', 'name'] });
120
+ const groupName = new Map(groups.map((g) => [g.ID, g.name]));
121
+ const g2u = await listPaged('listGroupsToUsers', { fields: ['group', 'user'] });
122
+ const userGroups = new Map();
123
+ for (const row of g2u) {
124
+ if (!userById.has(row.user)) continue;
125
+ if (!userGroups.has(row.user)) userGroups.set(row.user, []);
126
+ const name = groupName.get(row.group);
127
+ if (name) userGroups.get(row.user).push(name);
128
+ }
129
+ console.error(`· groups: ${groups.length}`);
130
+
131
+ // 3) Anchor "now" to the latest real activity (data may be a frozen snapshot).
132
+ const latestTicket = await listAll('listTickets', { fields: ['ID', 'date'], filters: { visibility: 0 }, sort: ['-date'], limit: 1 });
133
+ let asOf = latestTicket[0]?.date || REAL_NOW;
134
+
135
+ // 4) Tickets — velocity (indexed `date` for opened; lastmodified for closed).
136
+ const trendStart = asOf - TREND_WEEKS * 7 * DAY;
137
+ const windowStart = asOf - WINDOW_DAYS * DAY;
138
+ const recentTickets = await listAll('listTickets', {
139
+ fields: ['ID', 'assigneduser', 'status', 'date', 'creationdate', 'lastmodified'],
140
+ filters: { visibility: 0, date: { '>=': trendStart, '<=': asOf } }, sort: ['-date'],
141
+ });
142
+ const closedTickets = await listAll('listTickets', {
143
+ fields: ['ID', 'assigneduser', 'status', 'date', 'creationdate', 'lastmodified'],
144
+ filters: { visibility: 0, status: { IN: CLOSED_STATUSES }, lastmodified: { '>=': trendStart } }, sort: ['-lastmodified'],
145
+ });
146
+ const openTickets = await listAll('listTickets', {
147
+ fields: ['ID', 'assigneduser', 'status', 'duedate'],
148
+ filters: { visibility: 0, status: { IN: OPEN_STATUSES } },
149
+ });
150
+ const openTasks = await listAll('listTasks', {
151
+ fields: ['ID', 'assigneduser', 'status'],
152
+ filters: { visibility: 0, status: { IN: OPEN_STATUSES } },
153
+ });
154
+ console.error(`· tickets: ${recentTickets.length} opened / ${closedTickets.length} closed (trend) · ${openTickets.length} open · ${openTasks.length} tasks`);
155
+
156
+ // 5) Time entries — actionsteps (booked time). Bound to sane dates (≤ now) to
157
+ // skip corrupt far-future rows. Paged; extdata.type pulled via dot-field.
158
+ const contribStartRaw = asOf - (CONTRIB_WEEKS * 7 - 1) * DAY;
159
+ // snap contribution window start back to a Monday for clean week columns
160
+ const csWeekday = (new Date(contribStartRaw * 1000).getDay() + 6) % 7;
161
+ const contribStart = contribStartRaw - csWeekday * DAY;
162
+ const entries = await listPaged('listActionSteps', {
163
+ fields: ['ID', 'assigneduser', 'date', 'effort', 'ticket', 'account', 'extdata.type'],
164
+ filters: { status: { IN: TIME_STATUSES }, date: { '>=': contribStart, '<=': REAL_NOW } }, sort: ['-date'],
165
+ });
166
+ // entries arrive with `extdata_type`; normalise to `type`
167
+ for (const e of entries) { e.type = e.extdata_type || 'Untyped'; delete e.extdata_type; if (e.date > asOf) asOf = e.date; }
168
+ console.error(`· time entries: ${entries.length} (${CONTRIB_WEEKS}w)`);
169
+
170
+ await persistTokens();
171
+
172
+ // recompute windows against possibly-updated asOf
173
+ const winStart = asOf - WINDOW_DAYS * DAY;
174
+ const trStart = asOf - TREND_WEEKS * 7 * DAY;
175
+
176
+ // ── Distinct time-entry types → top N + Other (stable chart segmentation) ────
177
+ const typeTotals = new Map();
178
+ for (const e of entries) if (e.date >= winStart) typeTotals.set(e.type, (typeTotals.get(e.type) || 0) + e.effort);
179
+ const topTypes = [...typeTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_TYPES).map(([t]) => t);
180
+ const typeBucket = (t) => (topTypes.includes(t) ? t : 'Other');
181
+ const TYPE_ORDER = [...topTypes, ...(typeTotals.size > TOP_TYPES ? ['Other'] : [])];
182
+
183
+ // ── Velocity (tickets) ──────────────────────────────────────────────────────
184
+ const weeks = [];
185
+ for (let w = TREND_WEEKS - 1; w >= 0; w--) {
186
+ const s = asOf - (w + 1) * 7 * DAY, e = asOf - w * 7 * DAY;
187
+ weeks.push({ start: s, end: e, label: weekLabel(e),
188
+ opened: recentTickets.filter((t) => t.date >= s && t.date < e).length,
189
+ closed: closedTickets.filter((t) => t.lastmodified >= s && t.lastmodified < e).length });
190
+ }
191
+ const cycleDaysAll = closedTickets.filter((t) => t.lastmodified >= winStart)
192
+ .map((t) => (t.lastmodified - (t.creationdate || t.date)) / DAY).filter((d) => d >= 0 && d < 3650);
193
+ const cycle = { avgDays: round1(mean(cycleDaysAll)), medianDays: round1(median(cycleDaysAll)), p90Days: round1(percentile(cycleDaysAll, 90)), sampleSize: cycleDaysAll.length };
194
+ const openedInWindow = recentTickets.filter((t) => t.date >= winStart).length;
195
+ const closedInWindow = closedTickets.filter((t) => t.lastmodified >= winStart).length;
196
+ const overdueOpen = openTickets.filter((t) => t.duedate > 0 && t.duedate < asOf).length;
197
+
198
+ // ── Per-employee aggregation ─────────────────────────────────────────────────
199
+ const contribCols = Math.ceil((asOf - contribStart) / DAY / 7) + 1;
200
+ const dayIndex = (ts) => Math.floor((ts - contribStart) / DAY);
201
+
202
+ const byUser = new Map();
203
+ const ensure = (uid) => {
204
+ if (uid == null) return null;
205
+ if (!byUser.has(uid)) {
206
+ const u = userById.get(uid);
207
+ byUser.set(uid, {
208
+ id: uid, name: u?.name || `user#${uid}`, email: u?.email || '', active: !!u,
209
+ groups: userGroups.get(uid) || [],
210
+ openTickets: 0, overdueTickets: 0, openTasks: 0, openedInWindow: 0, closedInWindow: 0,
211
+ cycleDays: [], _entries: [], lastActivity: 0,
212
+ });
213
+ }
214
+ return byUser.get(uid);
215
+ };
216
+ for (const u of users) ensure(u.ID);
217
+
218
+ for (const t of openTickets) { const a = ensure(t.assigneduser); if (!a) continue; a.openTickets++; if (t.duedate > 0 && t.duedate < asOf) a.overdueTickets++; }
219
+ for (const t of openTasks) { const a = ensure(t.assigneduser); if (a) a.openTasks++; }
220
+ for (const t of recentTickets) { const a = ensure(t.assigneduser); if (a && t.date >= winStart) a.openedInWindow++; }
221
+ for (const t of closedTickets) {
222
+ const a = ensure(t.assigneduser); if (!a) continue;
223
+ if (t.lastmodified >= winStart) { a.closedInWindow++; const d = (t.lastmodified - (t.creationdate || t.date)) / DAY; if (d >= 0 && d < 3650) a.cycleDays.push(d); }
224
+ }
225
+ for (const e of entries) {
226
+ const a = ensure(e.assigneduser); if (!a) continue;
227
+ a._entries.push(e);
228
+ if (e.date > a.lastActivity) a.lastActivity = e.date;
229
+ }
230
+
231
+ // Resolve customer (account) + ticket numbers for the recent-entries hover.
232
+ // Many entries carry only a ticket, so backfill the customer via the ticket's account.
233
+ const accIds = new Set(), tkIds = new Set();
234
+ for (const a of byUser.values()) for (const e of a._entries.slice(0, 10)) { if (e.account) accIds.add(e.account); if (e.ticket) tkIds.add(e.ticket); }
235
+ const accName = new Map(), tkNum = new Map(), tkAccount = new Map();
236
+ if (tkIds.size) for (const t of await listAll('listTickets', { fields: ['ID', 'ticketnum', 'account'], filters: { ID: { IN: [...tkIds] } } })) {
237
+ tkNum.set(t.ID, t.ticketnum || `#${t.ID}`);
238
+ if (t.account) { tkAccount.set(t.ID, t.account); accIds.add(t.account); }
239
+ }
240
+ if (accIds.size) for (const acc of await listAll('listAccounts', { fields: ['ID', 'lastname', 'firstname'], filters: { ID: { IN: [...accIds] } } }))
241
+ accName.set(acc.ID, acc.lastname || acc.firstname || `#${acc.ID}`);
242
+ const customerOf = (e) => { const id = e.account || tkAccount.get(e.ticket); return id ? (accName.get(id) || `#${id}`) : ''; };
243
+
244
+ let employees = [...byUser.values()].map((e) => {
245
+ const entriesWin = e._entries.filter((x) => x.date >= winStart);
246
+ // weekly stacked-by-type (last TREND_WEEKS)
247
+ const weeklyByType = weeks.map((w) => {
248
+ const seg = {};
249
+ for (const x of e._entries) if (x.date >= w.start && x.date < w.end) seg[typeBucket(x.type)] = (seg[typeBucket(x.type)] || 0) + x.effort;
250
+ return { label: w.label, seg };
251
+ });
252
+ // contribution: sparse [dayIndex, count]
253
+ const contribMap = new Map();
254
+ for (const x of e._entries) { const d = dayIndex(x.date); if (d >= 0) contribMap.set(d, (contribMap.get(d) || 0) + 1); }
255
+ const contrib = [...contribMap.entries()].sort((a, b) => a[0] - b[0]);
256
+ // recent 10 entries for the hover
257
+ const recentEntries = e._entries.slice(0, 10).map((x) => ({
258
+ date: x.date, mins: x.effort, type: x.type,
259
+ customer: customerOf(x),
260
+ ticket: x.ticket ? (tkNum.get(x.ticket) || `#${x.ticket}`) : '',
261
+ }));
262
+ const throughput = e.closedInWindow, workload = e.openTickets + e.openTasks;
263
+ return {
264
+ id: e.id, name: e.name, email: e.email, active: e.active, groups: e.groups,
265
+ openTickets: e.openTickets, overdueTickets: e.overdueTickets, openTasks: e.openTasks,
266
+ openedInWindow: e.openedInWindow, closedInWindow: e.closedInWindow,
267
+ avgCycleDays: round1(mean(e.cycleDays)), throughput, workload,
268
+ lastActivity: e.lastActivity,
269
+ stale: e.lastActivity > 0 && (asOf - e.lastActivity) > STALE_DAYS * DAY,
270
+ bookedHours: round1(sum(entriesWin.map((x) => x.effort)) / 60),
271
+ entriesInWindow: entriesWin.length,
272
+ recentEntries, weeklyByType, contrib,
273
+ activityScore: workload + throughput + e.openedInWindow + entriesWin.length,
274
+ };
275
+ });
276
+
277
+ // ── Capacity classification (engaged-and-active cohort defines thresholds) ───
278
+ const engaged = employees.filter((e) => e.active && e.activityScore > 0);
279
+ const wLow = percentile(engaged.map((e) => e.workload), 33);
280
+ const wHigh = percentile(engaged.map((e) => e.workload), 80);
281
+ const tMed = median(engaged.map((e) => e.throughput));
282
+ for (const e of employees) {
283
+ if (!e.active) e.capacity = 'former';
284
+ else if (e.activityScore === 0) e.capacity = 'idle';
285
+ else if (e.workload >= wHigh || e.overdueTickets >= 3) e.capacity = 'overloaded';
286
+ else if (e.workload <= wLow && e.throughput <= tMed) e.capacity = 'available';
287
+ else e.capacity = 'balanced';
288
+ }
289
+ employees.sort((a, b) => b.activityScore - a.activityScore || b.throughput - a.throughput);
290
+
291
+ // ── Group facet for the filter (only groups engaged employees belong to) ─────
292
+ const groupCount = new Map();
293
+ for (const e of employees) if (e.capacity !== 'former') for (const g of e.groups) groupCount.set(g, (groupCount.get(g) || 0) + 1);
294
+ const groupFacet = [...groupCount.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name, count]) => ({ name, count }));
295
+
296
+ // ── Team-level time summary ──────────────────────────────────────────────────
297
+ const winEntries = entries.filter((e) => e.date >= winStart);
298
+ const byTypeHours = TYPE_ORDER.map((t) => ({ type: t, hours: round1(sum(winEntries.filter((e) => typeBucket(e.type) === t).map((e) => e.effort)) / 60) }));
299
+
300
+ const data = {
301
+ meta: {
302
+ instance: cred.baseUrl, generatedAt: REAL_NOW, asOf,
303
+ windowDays: WINDOW_DAYS, trendWeeks: TREND_WEEKS,
304
+ contribWeeks: CONTRIB_WEEKS, contribStart, contribCols, staleDays: STALE_DAYS,
305
+ typeOrder: TYPE_ORDER, closedStatuses: CLOSED_STATUSES, openStatuses: OPEN_STATUSES, timeStatuses: TIME_STATUSES,
306
+ },
307
+ velocity: {
308
+ openedInWindow, closedInWindow, net: openedInWindow - closedInWindow,
309
+ backlogOpen: openTickets.length, backlogOverdue: overdueOpen, openTasks: openTasks.length,
310
+ weeklyOpenedAvg: round1(mean(weeks.map((w) => w.opened))), weeklyClosedAvg: round1(mean(weeks.map((w) => w.closed))),
311
+ cycle, trend: weeks.map(({ label, opened, closed }) => ({ label, opened, closed })),
312
+ },
313
+ time: {
314
+ entries: winEntries.length, bookedHours: round1(sum(winEntries.map((e) => e.effort)) / 60),
315
+ byType: byTypeHours, staleEngineers: employees.filter((e) => e.active && e.stale).length,
316
+ },
317
+ team: {
318
+ activeUsers: users.length, engaged: engaged.length,
319
+ available: employees.filter((e) => e.capacity === 'available').length,
320
+ overloaded: employees.filter((e) => e.capacity === 'overloaded').length,
321
+ balanced: employees.filter((e) => e.capacity === 'balanced').length,
322
+ idle: employees.filter((e) => e.capacity === 'idle').length,
323
+ former: employees.filter((e) => e.capacity === 'former').length,
324
+ },
325
+ groups: groupFacet,
326
+ employees: employees.map(({ activityScore, ...rest }) => rest),
327
+ };
328
+
329
+ const json = JSON.stringify(data, null, 1);
330
+ writeFileSync(join(__dir, 'data.js'), `window.MISSION_DATA = ${json};\n`);
331
+ writeFileSync(join(__dir, 'data.json'), json + '\n');
332
+
333
+ console.error(`\n✓ Wrote data.js + data.json`);
334
+ console.error(` as of ${new Date(asOf * 1000).toISOString().slice(0, 10)} · ${openedInWindow} opened / ${closedInWindow} closed · ` +
335
+ `${data.time.bookedHours}h booked · ${data.time.staleEngineers} engineers stale (>${STALE_DAYS}d) · ` +
336
+ `${employees.length} employees · ${data.team.available} available, ${data.team.overloaded} overloaded`);
337
+ }
338
+
339
+ function argValue(flag) { const i = process.argv.indexOf(flag); return i >= 0 ? process.argv[i + 1] : undefined; }
340
+
341
+ main().catch((err) => { console.error(`\n✗ ${err.message}`); if (err.status) console.error(` HTTP ${err.status}`); process.exit(1); });