shakers-cli 0.15.0 → 0.17.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 +56 -1203
- package/bin/ai-usage.js +32 -58
- package/bin/login.js +10 -10
- package/bin/mcp.js +17 -12
- package/bin/mcpb.js +1 -1
- package/bin/onboarding.js +3 -2
- package/bin/register.js +19 -5
- package/bin/report-html.js +2 -2
- package/bin/shakers-cli.js +352 -0
- package/bin/shakers.js +20 -344
- package/bin/share.js +1 -1
- package/bin/superadmin.js +5 -5
- package/bin/version.js +3 -0
- package/package.json +12 -3
- package/src/account-marker.js +40 -0
- package/src/account-trace.js +23 -0
- package/src/agent-category.js +1 -1
- package/src/agent-evaluation.js +3 -3
- package/src/agent-org-chart.js +5 -5
- package/src/agent-synthesis.js +3 -3
- package/src/agent-usage.js +1 -1
- package/src/agents-client.js +4 -4
- package/src/ai-usage-review.js +101 -0
- package/src/ai.js +1 -1
- package/src/ansi.js +1 -1
- package/src/auth-client.js +18 -4
- package/src/auth-method-picker.js +1 -1
- package/src/auth-methods.js +1 -1
- package/src/auth-session-store.js +18 -8
- package/src/authorship-mismatch.js +1 -1
- package/src/authorship.js +2 -2
- package/src/automations-detector.js +1 -1
- package/src/backend-request.js +5 -3
- package/src/brand-ansi.js +1 -1
- package/src/browser-tools-detector.js +1 -1
- package/src/certify-disclaimer.js +1 -1
- package/src/certify-remediation-prompt.js +1 -1
- package/src/cli-args.js +2 -2
- package/src/command-audit.js +8 -5
- package/src/config-dir.js +1 -1
- package/src/config.js +114 -48
- package/src/consent-flow.js +7 -6
- package/src/consent-skip.js +1 -1
- package/src/detection-evidence.js +2 -2
- package/src/detection-noise.js +1 -1
- package/src/detectors.js +1 -1
- package/src/device-login.js +12 -1
- package/src/email-verification.js +3 -3
- package/src/env-paths.js +1 -1
- package/src/find-projects-client.js +12 -0
- package/src/find-projects-flow.js +1 -13
- package/src/git-activity.js +1 -1
- package/src/graph-certs.js +3 -3
- package/src/graph-scan.js +9 -9
- package/src/i18n.js +148 -138
- package/src/interactive-select.js +1 -1
- package/src/interview-client.js +6 -1
- package/src/interview-transcript.js +1 -1
- package/src/legal-notice.js +1 -1
- package/src/maturity.js +6 -6
- package/src/mcp-ai-usage-result-tool.js +34 -12
- package/src/mcp-ai-usage-tool.js +282 -163
- package/src/mcp-auth-form.js +7 -3
- package/src/mcp-detector.js +3 -3
- package/src/mcp-device-login-tools.js +2 -1
- package/src/mcp-find-projects-tools.js +1 -2
- package/src/mcp-install.js +279 -44
- package/src/mcp-instructions.js +1 -1
- package/src/mcp-onboarding-tools.js +4 -4
- package/src/mcp-prompts.js +4 -4
- package/src/mcp-register-tools.js +1 -0
- package/src/mcp-role-tools.js +24 -1
- package/src/mcp-server.js +5 -1
- package/src/mcp-signup-client.js +2 -2
- package/src/mcp-signup-tools.js +39 -9
- package/src/memory-structure-detector.js +1 -1
- package/src/model-call-record.js +2 -2
- package/src/onboarding-flow.js +63 -42
- package/src/onboarding-funnel.js +153 -20
- package/src/open-file.js +1 -1
- package/src/osc-link.js +1 -1
- package/src/portfolio-client.js +11 -1
- package/src/portfolio-git-info.js +1 -1
- package/src/profile-flow.js +1 -1
- package/src/profile-switch.js +5 -12
- package/src/render-certification.js +10 -10
- package/src/render-html.js +6 -6
- package/src/render-report-text.js +1 -1
- package/src/render-sheet.js +10 -10
- package/src/render-terminal.js +6 -6
- package/src/repo-scope.js +132 -0
- package/src/report-gating.js +5 -3
- package/src/report-store.js +13 -8
- package/src/roadmap-content.js +5 -5
- package/src/roadmap-personalization.js +3 -3
- package/src/roadmap-prompt.js +2 -2
- package/src/roles-flow.js +8 -5
- package/src/scan-exclusions.js +1 -1
- package/src/scanner.js +14 -14
- package/src/secret-prompt.js +1 -1
- package/src/session-gate.js +1 -1
- package/src/session-refresh.js +33 -3
- package/src/session-scan.js +1 -1
- package/src/shakers-wordmark.js +1 -1
- package/src/share-card.js +2 -2
- package/src/share-certification.js +4 -4
- package/src/share-consent-state.js +9 -9
- package/src/share-payload.js +7 -7
- package/src/share.js +7 -7
- package/src/signup-client.js +1 -1
- package/src/signup-copy.js +177 -2
- package/src/skills-client.js +1 -1
- package/src/start-add-agents.js +2 -2
- package/src/start-add-portfolio.js +3 -13
- package/src/start-add-skills.js +2 -2
- package/src/stdin-ask.js +16 -4
- package/src/strip-internal-comments.js +1 -1
- package/src/tech-detector.js +2 -2
- package/src/templates/report-sheet.html +11 -13
- package/src/terminal-agents.js +8 -8
- package/src/terminal-format.js +3 -3
- package/src/terminal-sections.js +11 -11
- package/src/tier-engine.js +3 -3
- package/src/tier-projection.js +2 -2
- package/src/transcript-turns.js +1 -1
- package/src/typewriter.js +1 -1
- package/src/usage-model-calls.js +6 -6
- package/src/usage-oneshot-commands.js +5 -5
- package/src/usage-repo-scope.js +11 -129
- package/src/usage-run-steps.js +82 -181
- package/src/usage-scan.js +235 -0
- package/src/work-streams.js +1 -1
package/src/onboarding-funnel.js
CHANGED
|
@@ -5,7 +5,7 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { getConfigDir } = require('./config-dir');
|
|
7
7
|
|
|
8
|
-
// Names and values mirror
|
|
8
|
+
// Names and values mirror the web onboarding funnel enums.
|
|
9
9
|
|
|
10
10
|
const FLOW_VERSION = 'signup_new_works_v1';
|
|
11
11
|
|
|
@@ -48,10 +48,32 @@ const WORK_SITUATION_VALUES = Object.freeze({
|
|
|
48
48
|
OTHER: 'other',
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
const PRE_ACCOUNT_EVENTS = Object.freeze({
|
|
52
|
+
start: { onboarding_step_viewed: [], onboarding_step_completed: ['time_on_step_ms'] },
|
|
53
|
+
account_creation: {
|
|
54
|
+
onboarding_step_viewed: ['auth_methods_offered'],
|
|
55
|
+
onboarding_step_errored: ['error_type'],
|
|
56
|
+
onboarding_step_exited: ['time_on_step_ms', 'exited_to_step_name', 'exited_to_step_index'],
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const AUTH_METHODS = Object.freeze(['google', 'linkedin', 'email']);
|
|
60
|
+
const PRE_ACCOUNT_VALUES = Object.freeze({
|
|
61
|
+
time_on_step_ms: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
62
|
+
auth_methods_offered: (v) => Array.isArray(v) && v.every((m) => AUTH_METHODS.includes(m)),
|
|
63
|
+
error_type: (v) => ERROR_TYPES.includes(v),
|
|
64
|
+
exited_to_step_name: (v) => v === 'start',
|
|
65
|
+
exited_to_step_index: (v) => v === STEP_INDEX.start,
|
|
66
|
+
});
|
|
67
|
+
const COMMON_PROPERTIES = new Set(['step_name', 'step_index', 'flow_version', 'surface', 'mcp_client']);
|
|
68
|
+
const MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
69
|
+
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
70
|
+
|
|
51
71
|
const ONCE_PER_STEP = new Set(['onboarding_step_viewed', 'onboarding_step_completed']);
|
|
52
72
|
const MAX_EVENTS_PER_REQUEST = 50;
|
|
53
73
|
const SEND_TIMEOUT_MS = 3000;
|
|
74
|
+
const EXIT_FLUSH_CAP_MS = 1500;
|
|
54
75
|
const DEVICE_FILE_NAME = 'analytics-device.json';
|
|
76
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
55
77
|
|
|
56
78
|
function mcpClientFor(clientName) {
|
|
57
79
|
const name = typeof clientName === 'string' ? clientName.toLowerCase() : '';
|
|
@@ -83,7 +105,8 @@ function rateTypes({ partTimeSelected, fullTimeSelected } = {}) {
|
|
|
83
105
|
|
|
84
106
|
function errorTypeFor(reason) {
|
|
85
107
|
const r = String(reason || '');
|
|
86
|
-
if (r === 'no-session' || r === 'no-hub-token' || r === 'http-401' || r === 'http-403') return 'auth';
|
|
108
|
+
if (r === 'no-session' || r === 'no-hub-token' || r === 'http-401' || r === 'http-403' || r === 'login-failed' || r.startsWith('google-')) return 'auth';
|
|
109
|
+
if (r === 'missing-fields' || r === 'weak-password') return 'validation';
|
|
87
110
|
if (r === 'timeout') return 'timeout';
|
|
88
111
|
if (r === 'no-endpoint') return 'server';
|
|
89
112
|
if (r.startsWith('bad-') || r.startsWith('no-') || r === 'duplicate-language' || r === 'unresolved-languages') return 'validation';
|
|
@@ -126,12 +149,27 @@ function loadOrCreateDeviceId(env = process.env, { fs: fsImpl = fs } = {}) {
|
|
|
126
149
|
}
|
|
127
150
|
}
|
|
128
151
|
|
|
129
|
-
async function
|
|
152
|
+
async function postEvents(endpoint, body, headers) {
|
|
130
153
|
const { postJsonWithTimeout } = require('./backend-request');
|
|
131
|
-
const res = await postJsonWithTimeout(endpoint,
|
|
154
|
+
const res = await postJsonWithTimeout(endpoint, body, SEND_TIMEOUT_MS, 'POST', null, headers);
|
|
132
155
|
return { ok: res.status >= 200 && res.status < 300, status: res.status };
|
|
133
156
|
}
|
|
134
157
|
|
|
158
|
+
function defaultSend({ endpoint, events, hubAccessToken }) {
|
|
159
|
+
return postEvents(endpoint, { events }, { Authorization: `Bearer ${hubAccessToken}` });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function defaultSendToAmplitude({ endpoint, apiKey, events }) {
|
|
163
|
+
return postEvents(endpoint, { api_key: apiKey, events }, null);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isPreAccount(item) {
|
|
167
|
+
const step = PRE_ACCOUNT_EVENTS[item.properties.step_name];
|
|
168
|
+
const allowed = step && step[item.eventType];
|
|
169
|
+
return !!allowed && Object.entries(item.properties)
|
|
170
|
+
.every(([key, value]) => COMMON_PROPERTIES.has(key) || (allowed.includes(key) && PRE_ACCOUNT_VALUES[key](value)));
|
|
171
|
+
}
|
|
172
|
+
|
|
135
173
|
function defaultHubToken() {
|
|
136
174
|
const { loadAuthSession, sessionStatus } = require('./auth-session-store');
|
|
137
175
|
const session = loadAuthSession();
|
|
@@ -143,7 +181,9 @@ function createFunnel({
|
|
|
143
181
|
hubToken = defaultHubToken,
|
|
144
182
|
deviceId = () => loadOrCreateDeviceId(),
|
|
145
183
|
endpoint = () => require('./config').getOnboardingEventsEndpoint(),
|
|
184
|
+
amplitude = () => require('./config').getAmplitudeIngestion(),
|
|
146
185
|
send = defaultSend,
|
|
186
|
+
sendToAmplitude = defaultSendToAmplitude,
|
|
147
187
|
now = () => Date.now(),
|
|
148
188
|
uuid = () => crypto.randomUUID(),
|
|
149
189
|
} = {}) {
|
|
@@ -156,8 +196,17 @@ function createFunnel({
|
|
|
156
196
|
let active = false;
|
|
157
197
|
let mcpClient = null;
|
|
158
198
|
let cachedDeviceId;
|
|
199
|
+
let begunAt = null;
|
|
200
|
+
let lastCompletedAt = null;
|
|
201
|
+
let heldFrom = null;
|
|
159
202
|
const seen = new Set();
|
|
160
203
|
const inflight = new Set();
|
|
204
|
+
const viewedAt = new Map();
|
|
205
|
+
|
|
206
|
+
function stepTime(stepName) {
|
|
207
|
+
const since = viewedAt.has(stepName) ? viewedAt.get(stepName) : (lastCompletedAt ?? begunAt);
|
|
208
|
+
return since === null ? undefined : Math.max(0, now() - since);
|
|
209
|
+
}
|
|
161
210
|
|
|
162
211
|
function track(eventType, stepName, properties = {}) {
|
|
163
212
|
if (!active || !EVENT_TYPES.includes(eventType) || !STEP_INDEX[stepName]) return false;
|
|
@@ -166,44 +215,111 @@ function createFunnel({
|
|
|
166
215
|
if (seen.has(key)) return false;
|
|
167
216
|
seen.add(key);
|
|
168
217
|
}
|
|
218
|
+
const at = now();
|
|
219
|
+
const own = cleanProperties(properties);
|
|
220
|
+
if (eventType === 'onboarding_step_viewed') viewedAt.set(stepName, at);
|
|
221
|
+
if (eventType === 'onboarding_step_completed') {
|
|
222
|
+
if (own.time_on_step_ms === undefined) {
|
|
223
|
+
const elapsed = stepTime(stepName);
|
|
224
|
+
if (elapsed !== undefined) own.time_on_step_ms = elapsed;
|
|
225
|
+
}
|
|
226
|
+
lastCompletedAt = at;
|
|
227
|
+
}
|
|
228
|
+
if (heldFrom === STEP_INDEX[stepName] && (eventType === 'onboarding_step_completed' || eventType === 'onboarding_step_skipped')) heldFrom = null;
|
|
169
229
|
const common = { step_name: stepName, step_index: STEP_INDEX[stepName], flow_version: FLOW_VERSION, surface };
|
|
170
230
|
if (surface === 'mcp') common.mcp_client = mcpClient || 'other';
|
|
171
|
-
pending.push({ eventType, insertId: uuid(), at
|
|
231
|
+
pending.push({ eventType, insertId: uuid(), at, seq: seq++, properties: { ...own, ...common } });
|
|
172
232
|
return true;
|
|
173
233
|
}
|
|
174
234
|
|
|
235
|
+
function currentDeviceId() {
|
|
236
|
+
if (cachedDeviceId === undefined) cachedDeviceId = safe(deviceId);
|
|
237
|
+
return cachedDeviceId;
|
|
238
|
+
}
|
|
239
|
+
|
|
175
240
|
// Funnel order, each time strictly after the previous one sent.
|
|
176
241
|
function stamp(batch) {
|
|
177
242
|
const ordered = [...batch].sort((a, b) => a.properties.step_index - b.properties.step_index || a.seq - b.seq);
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
243
|
+
const latest = [];
|
|
244
|
+
let next = Infinity;
|
|
245
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
246
|
+
const item = ordered[i];
|
|
247
|
+
latest[i] = item.time !== undefined ? item.time : Math.min(item.at, next - 1);
|
|
248
|
+
next = latest[i];
|
|
249
|
+
}
|
|
250
|
+
return ordered.map((item, i) => {
|
|
251
|
+
if (item.time === undefined) {
|
|
252
|
+
lastTime = Math.max(latest[i], lastTime + 1);
|
|
253
|
+
item.time = lastTime;
|
|
254
|
+
}
|
|
255
|
+
return { item, time: item.time };
|
|
182
256
|
});
|
|
183
257
|
}
|
|
184
258
|
|
|
185
|
-
function
|
|
186
|
-
|
|
259
|
+
function post(stamped, toWire, deliver) {
|
|
260
|
+
const sends = [];
|
|
261
|
+
for (let i = 0; i < stamped.length; i += MAX_EVENTS_PER_REQUEST) {
|
|
262
|
+
const chunk = stamped.slice(i, i + MAX_EVENTS_PER_REQUEST);
|
|
263
|
+
sends.push(Promise.resolve()
|
|
264
|
+
.then(() => deliver(chunk.map(toWire)))
|
|
265
|
+
.then((res) => !!(res && res.ok), () => false)
|
|
266
|
+
.then((ok) => (ok ? [] : chunk.map((e) => e.item))));
|
|
267
|
+
}
|
|
268
|
+
return Promise.all(sends).then((failed) => failed.flat());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function flushAsTalent() {
|
|
187
272
|
const token = safe(hubToken);
|
|
188
273
|
const url = safe(endpoint);
|
|
189
274
|
// Never send under another account signed in since.
|
|
190
275
|
if (!token || !url || accountOf(token) !== account) return Promise.resolve();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
276
|
+
const batch = heldFrom === null ? pending : pending.filter((e) => e.properties.step_index < heldFrom);
|
|
277
|
+
if (!batch.length) return Promise.resolve();
|
|
278
|
+
pending = pending.filter((e) => !batch.includes(e));
|
|
279
|
+
const id = currentDeviceId();
|
|
280
|
+
const toWire = ({ item, time }) => ({ event_type: item.eventType, insert_id: item.insertId, time, ...(id ? { device_id: id } : {}), properties: item.properties });
|
|
281
|
+
return post(stamp(batch), toWire, (events) => send({ endpoint: url, events, hubAccessToken: token })).then(() => undefined);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function flushToAmplitude() {
|
|
285
|
+
const id = currentDeviceId();
|
|
286
|
+
const target = safe(amplitude);
|
|
287
|
+
if (!id || !UUID_RE.test(id) || !target) return Promise.resolve();
|
|
288
|
+
const batch = pending.filter((e) => !e.directFailed && isPreAccount(e));
|
|
289
|
+
if (!batch.length) return Promise.resolve();
|
|
290
|
+
pending = pending.filter((e) => !batch.includes(e));
|
|
291
|
+
const at = now();
|
|
292
|
+
const stamped = stamp(batch);
|
|
293
|
+
const inWindow = ({ time }) => time >= at - MAX_EVENT_AGE_MS && time <= at + MAX_CLOCK_SKEW_MS;
|
|
294
|
+
const outOfWindow = stamped.filter((e) => !inWindow(e)).map((e) => e.item);
|
|
295
|
+
const toWire = ({ item, time }) => ({ event_type: item.eventType, device_id: id, insert_id: item.insertId, time, event_properties: item.properties });
|
|
296
|
+
return post(stamped.filter(inWindow), toWire, (events) => sendToAmplitude({ endpoint: target.endpoint, apiKey: target.apiKey, events })).then((failed) => {
|
|
297
|
+
const waiting = [...outOfWindow, ...failed];
|
|
298
|
+
if (!waiting.length) return undefined;
|
|
299
|
+
for (const e of waiting) e.directFailed = true;
|
|
300
|
+
pending.push(...waiting);
|
|
301
|
+
return ready ? flush() : undefined;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function flush() {
|
|
306
|
+
if (!pending.length) return Promise.resolve();
|
|
307
|
+
const done = (ready ? flushAsTalent() : flushToAmplitude()).then(() => undefined, () => undefined);
|
|
198
308
|
inflight.add(done);
|
|
199
309
|
done.then(() => inflight.delete(done));
|
|
200
310
|
return done;
|
|
201
311
|
}
|
|
202
312
|
|
|
313
|
+
const idle = () => Promise.all([...inflight]).then(() => undefined);
|
|
314
|
+
|
|
203
315
|
return {
|
|
204
316
|
surface,
|
|
205
317
|
begin() {
|
|
206
318
|
seen.clear();
|
|
319
|
+
viewedAt.clear();
|
|
320
|
+
lastCompletedAt = null;
|
|
321
|
+
heldFrom = null;
|
|
322
|
+
begunAt = now();
|
|
207
323
|
active = true;
|
|
208
324
|
},
|
|
209
325
|
get active() { return active; },
|
|
@@ -212,8 +328,13 @@ function createFunnel({
|
|
|
212
328
|
viewed: (step, props) => track('onboarding_step_viewed', step, props),
|
|
213
329
|
completed: (step, props) => track('onboarding_step_completed', step, props),
|
|
214
330
|
skipped: (step, props) => track('onboarding_step_skipped', step, props),
|
|
331
|
+
stepTime: (step) => (active ? stepTime(step) : undefined),
|
|
332
|
+
holdUntil(step) {
|
|
333
|
+
if (active && STEP_INDEX[step]) heldFrom = STEP_INDEX[step];
|
|
334
|
+
},
|
|
215
335
|
errored: (step, reason, props = {}) => track('onboarding_step_errored', step, { error_type: errorTypeFor(reason), ...props }),
|
|
216
|
-
identified() {
|
|
336
|
+
identified({ newAccount = true } = {}) {
|
|
337
|
+
if (!newAccount) pending = pending.filter((e) => !(e.eventType === 'onboarding_step_completed' && STEP_INDEX[e.properties.step_name] <= STEP_INDEX.account_creation));
|
|
217
338
|
const token = safe(hubToken);
|
|
218
339
|
if (!token) return Promise.resolve();
|
|
219
340
|
account = accountOf(token);
|
|
@@ -221,7 +342,14 @@ function createFunnel({
|
|
|
221
342
|
return flush();
|
|
222
343
|
},
|
|
223
344
|
flush,
|
|
224
|
-
idle
|
|
345
|
+
idle,
|
|
346
|
+
settle(capMs = EXIT_FLUSH_CAP_MS) {
|
|
347
|
+
heldFrom = null;
|
|
348
|
+
flush();
|
|
349
|
+
let timer;
|
|
350
|
+
const cap = new Promise((resolve) => { timer = setTimeout(resolve, capMs); });
|
|
351
|
+
return Promise.race([idle(), cap]).then(() => clearTimeout(timer));
|
|
352
|
+
},
|
|
225
353
|
};
|
|
226
354
|
}
|
|
227
355
|
|
|
@@ -244,10 +372,13 @@ const NOOP_FUNNEL = Object.freeze({
|
|
|
244
372
|
viewed: () => false,
|
|
245
373
|
completed: () => false,
|
|
246
374
|
skipped: () => false,
|
|
375
|
+
stepTime: () => undefined,
|
|
376
|
+
holdUntil() {},
|
|
247
377
|
errored: () => false,
|
|
248
378
|
identified: () => Promise.resolve(),
|
|
249
379
|
flush: () => Promise.resolve(),
|
|
250
380
|
idle: () => Promise.resolve(),
|
|
381
|
+
settle: () => Promise.resolve(),
|
|
251
382
|
});
|
|
252
383
|
|
|
253
384
|
module.exports = {
|
|
@@ -259,6 +390,8 @@ module.exports = {
|
|
|
259
390
|
SOURCE_TYPES,
|
|
260
391
|
ERROR_TYPES,
|
|
261
392
|
MAX_EVENTS_PER_REQUEST,
|
|
393
|
+
EXIT_FLUSH_CAP_MS,
|
|
394
|
+
PRE_ACCOUNT_EVENTS,
|
|
262
395
|
DEVICE_FILE_NAME,
|
|
263
396
|
NOOP_FUNNEL,
|
|
264
397
|
createFunnel,
|
package/src/open-file.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { spawn } = require('child_process');
|
|
4
4
|
|
|
5
|
-
// Zero-dependency, best-effort "open this file in the OS default app" (
|
|
5
|
+
// Zero-dependency, best-effort "open this file in the OS default app" (used by the `report` command to open the generated HTML in the browser).
|
|
6
6
|
function openPath(target) {
|
|
7
7
|
let cmd;
|
|
8
8
|
let args;
|
package/src/osc-link.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// OSC 8 terminal hyperlink helper (
|
|
3
|
+
// OSC 8 terminal hyperlink helper (links were printed as plain text and could NOT be clicked in iTerm2).
|
|
4
4
|
|
|
5
5
|
const OSC = '\x1b]8;;';
|
|
6
6
|
const ST = '\x1b\\';
|
package/src/portfolio-client.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { postJsonWithTimeout, postMultipartWithTimeout } = require('./backend-request');
|
|
4
4
|
const { reasonForError, WORKS_CODE_TO_REASON } = require('./skills-client');
|
|
5
5
|
|
|
6
|
-
// Talent-portfolio client (
|
|
6
|
+
// Talent-portfolio client (`start`'s "Add project to portfolio" route, src/start-add-portfolio.js).
|
|
7
7
|
|
|
8
8
|
const DEFAULT_TIMEOUT_MS = 20000;
|
|
9
9
|
|
|
@@ -155,10 +155,20 @@ async function requestUpdatePortfolioSkills(
|
|
|
155
155
|
return { ok: true };
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
function normalizeDomain(raw) {
|
|
159
|
+
let s = String(raw || '').trim().toLowerCase();
|
|
160
|
+
if (!s) return '';
|
|
161
|
+
s = s.replace(/^https?:\/\//, '');
|
|
162
|
+
s = s.split('/')[0].split('?')[0].split('#')[0];
|
|
163
|
+
s = s.replace(/^www\./, '');
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
166
|
+
|
|
158
167
|
module.exports = {
|
|
159
168
|
requestListPortfolios,
|
|
160
169
|
requestDeclarePortfolio,
|
|
161
170
|
requestDraftDescription,
|
|
162
171
|
requestUpdatePortfolioSkills,
|
|
163
172
|
WORKS_CODE_TO_REASON,
|
|
173
|
+
normalizeDomain,
|
|
164
174
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { execFileSync } = require('child_process');
|
|
4
4
|
|
|
5
|
-
// Git-derived signals for the "Add project to portfolio" route
|
|
5
|
+
// Git-derived signals for the "Add project to portfolio" route: the remote URL (step 4) and the first/last-commit date range (auto `startDate`/`endDate`).
|
|
6
6
|
|
|
7
7
|
const GIT_TIMEOUT_MS = 15000;
|
|
8
8
|
|
package/src/profile-flow.js
CHANGED
|
@@ -62,7 +62,7 @@ async function runProfile({ io, rawOut, session, catalog, opts = {}, deps = make
|
|
|
62
62
|
const agentsCount = aiProfile ? aiProfile.agentsOnProfile : null;
|
|
63
63
|
|
|
64
64
|
if (opts.json) {
|
|
65
|
-
|
|
65
|
+
rawOut(`${JSON.stringify({
|
|
66
66
|
ok: true, mainRole, headline, completedProfilePercentage: completion, freelanceType,
|
|
67
67
|
languageCodes, skillsCount, agentsCount, availability: availabilitySummary, aiProfile, pricing,
|
|
68
68
|
}, null, 2)}\n`);
|
package/src/profile-switch.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
4
|
|
|
6
|
-
const { getConfigDir } = require('./config-dir');
|
|
7
5
|
const { loadConfigFile, saveConfigFile, VALID_PROFILES } = require('./config');
|
|
8
6
|
const { clearAuthSession } = require('./auth-session-store');
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return path.join(getConfigDir(env), 'report-state.json');
|
|
12
|
-
}
|
|
13
|
-
function consentStatePath(env) {
|
|
14
|
-
return path.join(getConfigDir(env), 'consent.json');
|
|
15
|
-
}
|
|
7
|
+
const { consentPath } = require('./share-consent-state');
|
|
8
|
+
const { statePath } = require('./report-store');
|
|
16
9
|
|
|
17
10
|
// Best-effort delete: a file that doesn't exist (nothing to purge yet, e.g.
|
|
18
11
|
// a superadmin who never ran `usage` in this profile) is not a failure.
|
|
@@ -35,12 +28,12 @@ function switchProfile(target, env = process.env) {
|
|
|
35
28
|
saveConfigFile(config, env);
|
|
36
29
|
|
|
37
30
|
const purged = {
|
|
38
|
-
reportState: unlinkIfExists(
|
|
39
|
-
consent: unlinkIfExists(
|
|
31
|
+
reportState: unlinkIfExists(statePath(env)),
|
|
32
|
+
consent: unlinkIfExists(consentPath(env)),
|
|
40
33
|
authSession: clearAuthSession(env),
|
|
41
34
|
};
|
|
42
35
|
|
|
43
36
|
return { ok: true, from, to: target, purged };
|
|
44
37
|
}
|
|
45
38
|
|
|
46
|
-
module.exports = { switchProfile
|
|
39
|
+
module.exports = { switchProfile };
|
|
@@ -26,7 +26,7 @@ function anyTruncated(items) {
|
|
|
26
26
|
return (items || []).some((i) => i && i.sampling && i.sampling.truncated);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
//
|
|
29
|
+
// Rubric dimension order (terminal).
|
|
30
30
|
const DIMENSION_KEYS = ['idiomatic', 'correctness', 'depth', 'structure', 'testing'];
|
|
31
31
|
|
|
32
32
|
// Score band -> semantic (shared with the HTML/graph CSS class via `graph-certs.js`, which still imports this).
|
|
@@ -37,7 +37,7 @@ function scoreBand(score) {
|
|
|
37
37
|
return 'low';
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
40
|
+
// Named skill level (by decision power, not points) shown INSTEAD of the numeric grade.
|
|
41
41
|
function skillLevelForScore(score) {
|
|
42
42
|
const band = scoreBand(score);
|
|
43
43
|
if (typeof score !== 'number') return { key: null, band };
|
|
@@ -67,18 +67,18 @@ function renderCertificationTerminal(certification, lang, opts = {}) {
|
|
|
67
67
|
const r = getCatalog(lang).certify.report;
|
|
68
68
|
const items = Array.isArray(certification && certification.items) ? certification.items : [];
|
|
69
69
|
const lines = [];
|
|
70
|
-
//
|
|
70
|
+
// The SAME identity gate the footprint report reads.
|
|
71
71
|
const gate = opts.gate || DEFAULT_REPORT_GATE;
|
|
72
72
|
|
|
73
73
|
// Header stands out; disclaimer + cost note are deliberately quiet (dim) so
|
|
74
|
-
// the per-Skill results below are visually dominant
|
|
74
|
+
// the per-Skill results below are visually dominant.
|
|
75
75
|
lines.push(`${C.bold}${C.primary}${r.heading}${C.reset}`);
|
|
76
|
-
//
|
|
76
|
+
// This whole render is now a FALLBACK — reached only when the mandatory interview did not complete.
|
|
77
77
|
if (opts.noInterview && r.noInterviewNote) lines.push(`${C.bold}${C.warning}${r.noInterviewNote}${C.reset}`);
|
|
78
78
|
lines.push('');
|
|
79
79
|
lines.push(`${C.dim}${r.disclaimer}${C.reset}`);
|
|
80
80
|
lines.push(`${C.dim}${r.costNote}${C.reset}`);
|
|
81
|
-
//
|
|
81
|
+
// Authorship receipt (run-level): repo + commit range + honest note,
|
|
82
82
|
// shown once. Attribution is based on git authorship, NOT cryptographic proof.
|
|
83
83
|
const authorship = (certification && certification.authorship) || null;
|
|
84
84
|
const rc = r.receipt;
|
|
@@ -114,10 +114,10 @@ function renderCertificationTerminal(certification, lang, opts = {}) {
|
|
|
114
114
|
continue;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
//
|
|
117
|
+
// The HEADLINE is the named level (Middle/Senior/Expert) instead of the numeric 0-100 grade.
|
|
118
118
|
const { key: levelKey } = skillLevelForScore(item.result.score);
|
|
119
119
|
lines.push(`${C.primary}│${C.reset} ${C.bold}${C.accent}${r.levelLine(skillLevelLabel(levelKey, r))}${C.reset}`);
|
|
120
|
-
//
|
|
120
|
+
// Show the anchored rubric dimensions so the level is explainable.
|
|
121
121
|
if (item.result.dimensions && r.dimensionsLabel && r.dimensionLabels) {
|
|
122
122
|
lines.push(`${C.primary}│${C.reset} ${C.bold}${r.dimensionsLabel}:${C.reset}`);
|
|
123
123
|
for (const key of DIMENSION_KEYS) {
|
|
@@ -138,7 +138,7 @@ function renderCertificationTerminal(certification, lang, opts = {}) {
|
|
|
138
138
|
const tag = item.sampling.truncated ? ` ${r.partialTag}` : '';
|
|
139
139
|
lines.push(`${C.primary}│${C.reset} ${C.dim}${summary}${tag}${C.reset}`);
|
|
140
140
|
}
|
|
141
|
-
//
|
|
141
|
+
// Per-Skill authorship receipt (compact in terminal): attributed
|
|
142
142
|
// file count + the author emails confirmed against the identity.
|
|
143
143
|
if (rc && Array.isArray(item.fileAttribution) && item.fileAttribution.length > 0) {
|
|
144
144
|
const attributed = item.fileAttribution.filter((f) => f.attributed).length;
|
|
@@ -151,7 +151,7 @@ function renderCertificationTerminal(certification, lang, opts = {}) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Remediation prompt
|
|
154
|
+
// Remediation prompt: a clearly delimited copyable block.
|
|
155
155
|
const remediation = gate.showCertifyRemediation ? buildRemediationPrompt(item, lang) : null;
|
|
156
156
|
if (remediation) {
|
|
157
157
|
lines.push(`${C.primary}│${C.reset}`);
|
package/src/render-html.js
CHANGED
|
@@ -8,7 +8,7 @@ function normalizeAgentName(name) {
|
|
|
8
8
|
return String(name || '').trim().toLowerCase().replace(/^[`'"*]+|[`'"*]+$/g, '');
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
//
|
|
11
|
+
// TWO earlier approaches were tried and rejected before this one.
|
|
12
12
|
|
|
13
13
|
function humanizeAgentName(name) {
|
|
14
14
|
const spaced = String(name || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
@@ -38,9 +38,9 @@ function excerptForCard(text, maxLen = CARD_EXCERPT_MAX_LEN) {
|
|
|
38
38
|
return `${text.slice(0, maxLen).trim()}…`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// Every card field that is TALENT-AUTHORED or came OFF THE WIRE, neutralised once, here
|
|
41
|
+
// Every card field that is TALENT-AUTHORED or came OFF THE WIRE, neutralised once, here.
|
|
42
42
|
|
|
43
|
-
// Every card field that is TALENT-AUTHORED or came OFF THE WIRE, neutralised once, here
|
|
43
|
+
// Every card field that is TALENT-AUTHORED or came OFF THE WIRE, neutralised once, here.
|
|
44
44
|
function sanitizeCardText(v) {
|
|
45
45
|
return typeof v === 'string' ? sanitizeRenderText(v) : v;
|
|
46
46
|
}
|
|
@@ -58,7 +58,7 @@ function buildAgentCardTree(report, t) {
|
|
|
58
58
|
const rawDescByName = new Map(rawDescriptions.map((d) => [normalizeAgentName(d.name), d.description]));
|
|
59
59
|
const byName = new Set(agents.map((a) => a.name));
|
|
60
60
|
|
|
61
|
-
//
|
|
61
|
+
// Agent evaluation: the ephemeral LLM definition-quality score (+ rationale) keyed by normalized name, and the LOCAL usage count keyed by exact name.
|
|
62
62
|
const evaluations =
|
|
63
63
|
report.agentEvaluation && Array.isArray(report.agentEvaluation.evaluations)
|
|
64
64
|
? report.agentEvaluation.evaluations
|
|
@@ -111,7 +111,7 @@ function buildAgentCardTree(report, t) {
|
|
|
111
111
|
symbolicName: sanitizeCardText(synth && synth.symbolicName ? synth.symbolicName : null),
|
|
112
112
|
whatItDoes: sanitizeCardText(whatItDoes),
|
|
113
113
|
tools: sanitizeCardList(tools),
|
|
114
|
-
// The declared LLM model
|
|
114
|
+
// The declared LLM model.
|
|
115
115
|
model: sanitizeCardText(a.model),
|
|
116
116
|
aiProduct,
|
|
117
117
|
parent: sanitizeCardText(parentKey),
|
|
@@ -120,7 +120,7 @@ function buildAgentCardTree(report, t) {
|
|
|
120
120
|
? { ...classification, role: sanitizeCardText(classification.role) }
|
|
121
121
|
: classification,
|
|
122
122
|
improvements: sanitizeCardList(improvements),
|
|
123
|
-
//
|
|
123
|
+
// The provenance rule, applied to the fourth state: ONE field, on the card both surfaces read, so the terminal and the shared report cannot disagree about the same agent.
|
|
124
124
|
evaluationState,
|
|
125
125
|
};
|
|
126
126
|
});
|
|
@@ -14,7 +14,7 @@ function renderFullReportText(report, maturity, aiProfile, { lang = 'en', gate }
|
|
|
14
14
|
);
|
|
15
15
|
let terminal = '';
|
|
16
16
|
try {
|
|
17
|
-
terminal = stripAnsi(renderTerminal(report, maturity, lang, { gate: gate || reportGateForIdentity() }))
|
|
17
|
+
terminal = stripAnsi(renderTerminal(report, maturity, lang, { gate: { ...(gate || reportGateForIdentity()), showRoadmap: false } }))
|
|
18
18
|
.replace(/[ \t]+$/gm, '')
|
|
19
19
|
.replace(/\n{3,}/g, '\n\n')
|
|
20
20
|
.trim();
|
package/src/render-sheet.js
CHANGED
|
@@ -21,7 +21,7 @@ function aiFluencyHtml(report, lang) {
|
|
|
21
21
|
|
|
22
22
|
const TEMPLATE_PATH = path.join(__dirname, 'templates', 'report-sheet.html');
|
|
23
23
|
let _tpl = null;
|
|
24
|
-
//
|
|
24
|
+
// The template's comments are OURS and this document is the one the talent shares, so they are swept here — at load, before any talent data is in the string.
|
|
25
25
|
function template() {
|
|
26
26
|
if (_tpl == null) _tpl = stripTemplateComments(fs.readFileSync(TEMPLATE_PATH, 'utf8'));
|
|
27
27
|
return _tpl;
|
|
@@ -54,7 +54,7 @@ function tierName(fp, t) {
|
|
|
54
54
|
|
|
55
55
|
function bandClass(b) { return b === 'high' ? 'band-high' : b === 'mid' ? 'band-mid' : 'band-low'; }
|
|
56
56
|
|
|
57
|
-
// Localized Setup Level label from the drawer payload
|
|
57
|
+
// Localized Setup Level label from the drawer payload: prefer the
|
|
58
58
|
// i18n `setupLevels` catalog by key, fall back to the label baked into `fp`.
|
|
59
59
|
function setupLabel(fp, t) {
|
|
60
60
|
const key = (fp.setup && fp.setup.key) || 'none';
|
|
@@ -65,7 +65,7 @@ function setupLabel(fp, t) {
|
|
|
65
65
|
function heroHtml(fp, c, t) {
|
|
66
66
|
const rank = (fp.setup && typeof fp.setup.rank === 'number') ? fp.setup.rank : 0;
|
|
67
67
|
const label = setupLabel(fp, t);
|
|
68
|
-
// Four pips: Not certified + S1/S2/S3, current filled
|
|
68
|
+
// Four pips: Not certified + S1/S2/S3, current filled.
|
|
69
69
|
const pills = Array.from({ length: 4 }, (_, i) => `<span class="${i <= rank ? 'on' : ''}"></span>`).join('');
|
|
70
70
|
const chipK = esc(fp.tier.key && fp.tier.key !== 'none' ? fp.tier.key : (fp.setup && fp.setup.code) || '');
|
|
71
71
|
return `<div class="card reveal">
|
|
@@ -86,7 +86,7 @@ function heroHtml(fp, c, t) {
|
|
|
86
86
|
</div>`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
// The Setup Level ladder
|
|
89
|
+
// The Setup Level ladder, with each rung's tiers and short meaning inline — meanings from the localized i18n `setupLevels` catalog (never invented).
|
|
90
90
|
function ladderHtml(fp, c, t) {
|
|
91
91
|
const cur = (fp.setup && typeof fp.setup.rank === 'number') ? fp.setup.rank : 0;
|
|
92
92
|
const catalog = (t && t.setupLevels) || {};
|
|
@@ -111,7 +111,7 @@ function chipsCard(title, sub, items, withDot) {
|
|
|
111
111
|
return `<div class="card reveal"><h3>${esc(title)}</h3><p class="sub">${esc(sub)}</p><div class="chips">${body}</div></div>`;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
// MCP SERVICES
|
|
114
|
+
// MCP SERVICES.
|
|
115
115
|
function mcpCard(mcp, c) {
|
|
116
116
|
const services = (mcp && Array.isArray(mcp.services) ? mcp.services : []);
|
|
117
117
|
const unidentified = (mcp && typeof mcp.unidentified === 'number') ? mcp.unidentified : 0;
|
|
@@ -129,17 +129,17 @@ function mcpCard(mcp, c) {
|
|
|
129
129
|
return `<div class="card reveal"><h3>${esc(c.mcpT)}</h3><p class="sub">${esc(c.mcpS)}</p><div class="chips">${chips}</div>${note}</div>`;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
// DETECTED AGENTS, left column
|
|
132
|
+
// DETECTED AGENTS, left column.
|
|
133
133
|
function agentRow(a, c, t) {
|
|
134
134
|
const cats = (t.classification && t.classification.categories) || {};
|
|
135
135
|
const model = a.model ? `<span class="model">${esc(a.model)}</span>` : `<span class="model" style="opacity:.6">${esc(c.agentNoModel)}</span>`;
|
|
136
136
|
// The category is the agent → catalog-seed association (per agent), not the Talent's tier.
|
|
137
137
|
const catLabel = a.category ? (cats[a.category] || sanitizeRenderText(a.category)) : null;
|
|
138
138
|
// ONE COLOUR PER CATEGORY (user request), and THREE distinct facts, not two: - a known catalog category -> its own colour token (`.cat-developer` …).
|
|
139
|
-
// FOUR facts now, not three
|
|
139
|
+
// FOUR facts now, not three.
|
|
140
140
|
const coloured = !!(a.category && cats[a.category] && !isFloorCategory(a.category));
|
|
141
141
|
const catClass = coloured ? `cat cat-${a.category}` : 'cat cat-none';
|
|
142
|
-
//
|
|
142
|
+
// The empty badge says WHICH kind of empty it is.
|
|
143
143
|
const cls = t.classification || {};
|
|
144
144
|
const emptyLabel = a.evaluationState === 'omitted'
|
|
145
145
|
? cls.agentEvalOmitted
|
|
@@ -163,7 +163,7 @@ function agentRow(a, c, t) {
|
|
|
163
163
|
|
|
164
164
|
function agentsCard(agents, c, t) {
|
|
165
165
|
const list = Array.isArray(agents) ? agents : [];
|
|
166
|
-
// SAID ONCE, VISIBLY
|
|
166
|
+
// SAID ONCE, VISIBLY: if nobody was evaluated, the reason the badges are empty is that the run could not classify, not that the agents have no category.
|
|
167
167
|
const noneEvaluated = list.length > 0 && list.every((a) => a.evaluated === false);
|
|
168
168
|
// AND THE PARTIAL RUN, NAMED (2026-08-03).
|
|
169
169
|
const omitted = list.filter((a) => a.evaluationState === 'omitted').map((a) => a.name);
|
|
@@ -196,7 +196,7 @@ function renderSheet(project, lang, gate = DEFAULT_REPORT_GATE) {
|
|
|
196
196
|
const t = getCatalog(L);
|
|
197
197
|
const c = t.sheet;
|
|
198
198
|
const hasFoot = !!(project && project.footprint && project.footprint.report);
|
|
199
|
-
// `t` is passed now
|
|
199
|
+
// `t` is passed now: the drawer's agent rows use the catalog for the
|
|
200
200
|
// description fallback and nothing else — it stays optional for the graph report.
|
|
201
201
|
const fp = hasFoot ? buildFootprintDrawer(project.footprint.report, project.footprint.maturity, t) : null;
|
|
202
202
|
// buildCertsPayload returns null when the project has no Skill certs — the sheet still renders both columns, so normalize to an empty list (clean state).
|
package/src/render-terminal.js
CHANGED
|
@@ -13,7 +13,7 @@ const {
|
|
|
13
13
|
printRoadmap,
|
|
14
14
|
} = require('./terminal-sections');
|
|
15
15
|
|
|
16
|
-
//
|
|
16
|
+
// Terminal parity with the HTML report.
|
|
17
17
|
|
|
18
18
|
// `lang` ('es'|'en', see src/i18n.js) decides the text catalog.
|
|
19
19
|
function renderTerminal(report, maturity, lang, opts = {}) {
|
|
@@ -21,10 +21,10 @@ function renderTerminal(report, maturity, lang, opts = {}) {
|
|
|
21
21
|
const lines = [];
|
|
22
22
|
const p = (s = '') => lines.push(s);
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// The ONE identity gate.
|
|
25
25
|
const gate = opts.gate || DEFAULT_REPORT_GATE;
|
|
26
26
|
|
|
27
|
-
//
|
|
27
|
+
// Setup Level (S1-S3 / Not certified) replaces the 0-4 band label.
|
|
28
28
|
const setupKey = (maturity.setupLevel && maturity.setupLevel.key) || 'none';
|
|
29
29
|
const setupLabel = (t.setupLevels && t.setupLevels[setupKey] && t.setupLevels[setupKey].label) || setupKey;
|
|
30
30
|
|
|
@@ -47,7 +47,7 @@ function renderTerminal(report, maturity, lang, opts = {}) {
|
|
|
47
47
|
sep(p);
|
|
48
48
|
const tierName = (maturity.tierKey && t.tierNames[maturity.tierKey]) || maturity.tierName || '';
|
|
49
49
|
const tierBit = maturity.tierKey ? t.terminal.tierInline(maturity.tierKey, tierName) : '';
|
|
50
|
-
// This report's ONE headline
|
|
50
|
+
// This report's ONE headline: the Setup Level IS the evaluation's main result, so it gets `accent` (lime) — and nothing else on this screen does.
|
|
51
51
|
const setupColor = setupKey === 'none' ? c.white : c.accent;
|
|
52
52
|
p(` ${c.bold}${setupColor}${t.terminal.setupLevel(setupLabel)}${c.reset}${c.muted}${tierBit}${c.reset}`);
|
|
53
53
|
p(` ${c.primary}${bar(maturity.score)}${c.reset} ${c.dim}${maturity.score}/100${c.reset}`);
|
|
@@ -77,7 +77,7 @@ function renderTerminal(report, maturity, lang, opts = {}) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// 4b. MCP services
|
|
80
|
+
// 4b. MCP services — ALWAYS printed, empty state included, because
|
|
81
81
|
// its absence is a tier criterion the talent needs to know about.
|
|
82
82
|
sep(p);
|
|
83
83
|
printMcpServices(report, t, p);
|
|
@@ -105,5 +105,5 @@ function renderTerminal(report, maturity, lang, opts = {}) {
|
|
|
105
105
|
return lines.join('\n');
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// `wrap` is re-exported for its own unit test
|
|
108
|
+
// `wrap` is re-exported for its own unit test: it is the one piece of the formatting with edge cases worth pinning directly.
|
|
109
109
|
module.exports = { renderTerminal, wrap, printAgents };
|