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/mcp-ai-usage-tool.js
CHANGED
|
@@ -75,11 +75,36 @@ function buildArgv({ root, repoScope, lang }) {
|
|
|
75
75
|
return argv;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
const VERBATIM = 'Print relayVerbatim word for word now, as its own block, with nothing of your own added to it.';
|
|
79
|
+
|
|
80
|
+
const UPLOAD_INPUT_SCHEMA = {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
confirm: {
|
|
84
|
+
type: 'boolean',
|
|
85
|
+
description: 'Chat only: true ONLY when the talent picked the upload option in the review this tool or ai_usage showed them; false when they picked not to upload. Omit it to show the review.',
|
|
86
|
+
},
|
|
87
|
+
exclude: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
description: 'What the talent asked to leave out, with confirm:true. Names exactly as listed in `excludable`.',
|
|
90
|
+
properties: {
|
|
91
|
+
repos: { type: 'array', items: { type: 'string' } },
|
|
92
|
+
agents: { type: 'array', items: { type: 'string' } },
|
|
93
|
+
excerpts: { type: 'boolean', description: 'true = send no text excerpts.' },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
waitSeconds: { type: 'number', description: 'Sign-up only: how long to wait for the background analysis before answering (max 45).' },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const MAX_WAIT_MS = 45000;
|
|
101
|
+
|
|
102
|
+
function makeAiUsageTools(deps = {}) {
|
|
79
103
|
const {
|
|
80
|
-
scanUsage = (argv) => require('
|
|
104
|
+
scanUsage = (argv) => require('./usage-scan').scanUsage(argv),
|
|
81
105
|
autoShare = require('./share').autoShare,
|
|
82
106
|
recordConsent = require('./share').recordConsent,
|
|
107
|
+
sendThrottled = () => require('./share').isThrottled(require('./share').loadConsentState()),
|
|
83
108
|
isValidEmail = require('./share').isValidEmail,
|
|
84
109
|
persistFootprint = require('./report-store').persistFootprint,
|
|
85
110
|
cwdIsRepo = defaultCwdIsRepo,
|
|
@@ -90,64 +115,218 @@ function makeAiUsageTool(deps = {}) {
|
|
|
90
115
|
pollClassifiedInventory = require('./mcp-ai-usage-result-tool').pollClassifiedInventory,
|
|
91
116
|
tierLadder = require('./tier-engine').tierLadder,
|
|
92
117
|
inlineWaitMs = 35000,
|
|
93
|
-
// "My work with AI" preview, from the same hub endpoint the front paints.
|
|
94
118
|
resolveAiProfilePreview = require('./ai-profile-preview').resolveAiProfilePreview,
|
|
95
119
|
ensureFreshSession = require('./session-refresh').ensureFreshSession,
|
|
96
120
|
getAiProfileEndpoint = require('./config').getAiProfileEndpoint,
|
|
97
|
-
// Plain-text render of the full report (terminal sections + matrix/vision/howIWork), shared with the CLI (no ANSI).
|
|
98
121
|
renderFullReportText = require('./render-report-text').renderFullReportText,
|
|
99
122
|
detectFlowLang = require('./i18n').detectFlowLang,
|
|
100
123
|
aiFluencyWaitMs = 30000,
|
|
101
124
|
signupPhase = () => require('./mcp-signup-tools').signupPhase(),
|
|
102
125
|
usageDisclaimers = (lang) => require('./onboarding-flow').usageDisclaimers(lang),
|
|
103
126
|
signupCopy = require('./signup-copy').signupCopy,
|
|
127
|
+
reviewCopy = require('./signup-copy').reviewCopy,
|
|
104
128
|
signupLanguage = require('./signup-language').signupLanguage,
|
|
129
|
+
reviewTexts = require('./ai-usage-review'),
|
|
105
130
|
} = deps;
|
|
106
131
|
|
|
107
|
-
// The sign-up's one scan: started on the talent's yes once the account exists, uploaded in the background.
|
|
108
132
|
let job = null;
|
|
109
133
|
// The talent's answer in the consent dialog, so a later call cannot contradict it.
|
|
110
134
|
let dialogConsent = null;
|
|
135
|
+
let pending = null;
|
|
111
136
|
|
|
112
|
-
function startSignupScan(argv, root) {
|
|
113
|
-
if (job) return job;
|
|
114
|
-
job = { state: 'scanning',
|
|
137
|
+
function startSignupScan(argv, root, lang) {
|
|
138
|
+
if (job && job.state !== 'failed') return job;
|
|
139
|
+
job = { state: 'scanning', root, lang };
|
|
115
140
|
// performScan is synchronous: start it after this answer has gone out.
|
|
116
141
|
job.scan = new Promise((resolve) => { setImmediate(resolve); }).then(() => scanUsage(argv)).then((scan) => {
|
|
117
142
|
try { persistFootprint({ root, report: scan.report, maturity: scan.maturity }); } catch { /* local copy is best-effort */ }
|
|
143
|
+
job.state = 'scanned';
|
|
118
144
|
return scan;
|
|
119
145
|
});
|
|
120
146
|
job.scan.catch(() => { job.state = 'failed'; });
|
|
121
|
-
job.root = root;
|
|
122
147
|
return job;
|
|
123
148
|
}
|
|
124
149
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
150
|
+
function activeSession() {
|
|
151
|
+
const session = loadAuthSession();
|
|
152
|
+
return sessionStatus(session) === 'active' && session && isValidEmail(session.email) ? session : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function openReview({ scan, root, lang, registration }) {
|
|
156
|
+
pending = { scan, root, lang, registration, askedInChat: false, outcome: null };
|
|
157
|
+
return pending;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function reviewFacts(review) {
|
|
161
|
+
return reviewTexts.uploadFacts({ report: review.scan.report, maturity: review.scan.maturity, repos: review.scan.repos || [] });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function reviewSchema(c, facts) {
|
|
165
|
+
const properties = { decision: { type: 'string', title: c.question, enum: [c.yes, c.no] } };
|
|
166
|
+
if (facts.conversationExcerpts + facts.commandExcerpts > 0) properties.excerpts = { type: 'boolean', title: c.formExcerpts, default: true };
|
|
167
|
+
if (facts.repos.length > 1) facts.repos.forEach((name, i) => { properties[`repo_${i}`] = { type: 'boolean', title: c.formRepo(name), default: true }; });
|
|
168
|
+
facts.agents.forEach((name, i) => { properties[`agent_${i}`] = { type: 'boolean', title: c.formAgent(name), default: true }; });
|
|
169
|
+
return { type: 'object', properties, required: ['decision'] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function exclusionsFromForm(review, facts, content) {
|
|
173
|
+
const repos = review.scan.repos || [];
|
|
174
|
+
return {
|
|
175
|
+
repos: repos.filter((_, i) => content[`repo_${i}`] === false),
|
|
176
|
+
agents: facts.agents.filter((_, i) => content[`agent_${i}`] === false),
|
|
177
|
+
excerpts: content.excerpts !== false,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function exclusionsFromChat(review, exclude) {
|
|
182
|
+
const e = exclude && typeof exclude === 'object' ? exclude : {};
|
|
183
|
+
const names = new Set((Array.isArray(e.repos) ? e.repos : []).map(String));
|
|
184
|
+
const known = review.scan.repos || [];
|
|
185
|
+
const repos = known.filter((r) => names.has(r) || names.has(reviewTexts.repoName(r)));
|
|
186
|
+
const agentNames = reviewFacts(review).agents;
|
|
187
|
+
const agents = Array.isArray(e.agents) ? e.agents.map(String) : [];
|
|
188
|
+
const unknown = [
|
|
189
|
+
...[...names].filter((n) => !known.some((r) => r === n || reviewTexts.repoName(r) === n)),
|
|
190
|
+
...agents.filter((a) => !agentNames.includes(a)),
|
|
191
|
+
];
|
|
192
|
+
return { repos, agents, excerpts: e.excerpts !== true, unknown };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function declineUpload(review) {
|
|
196
|
+
const c = reviewCopy(review.lang);
|
|
197
|
+
review.outcome = { ok: false, reason: 'upload-declined', relayVerbatim: c.declined, message: `Nothing was sent. ${VERBATIM} Then carry on.` };
|
|
198
|
+
return review.outcome;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function presentReview(review, ctx) {
|
|
202
|
+
const c = reviewCopy(review.lang);
|
|
203
|
+
if (!review.registration && sendThrottled()) {
|
|
204
|
+
return { ok: false, reason: 'throttled', relayVerbatim: c.throttled, message: `Nothing was sent. ${VERBATIM}` };
|
|
205
|
+
}
|
|
206
|
+
const facts = reviewFacts(review);
|
|
207
|
+
const texts = [reviewTexts.summaryText(review.lang, facts), reviewTexts.explanationText(review.lang)];
|
|
208
|
+
if (ctx && ctx.elicitation && typeof ctx.elicit === 'function') {
|
|
209
|
+
const reply = await ctx.elicit({ message: [...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
|
|
210
|
+
const content = reply && reply.action === 'accept' && reply.content ? reply.content : null;
|
|
211
|
+
if (content && content.decision === c.yes) return upload(review, exclusionsFromForm(review, facts, content));
|
|
212
|
+
if ((content && content.decision === c.no) || (reply && reply.action === 'decline')) return declineUpload(review);
|
|
213
|
+
}
|
|
214
|
+
review.askedInChat = true;
|
|
215
|
+
const chat = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: 'upload-approval-required',
|
|
219
|
+
say: chat.say,
|
|
220
|
+
options: chat.options,
|
|
221
|
+
excludable: { repos: facts.repos, agents: facts.agents, excerpts: facts.conversationExcerpts + facts.commandExcerpts > 0 },
|
|
222
|
+
message: `Nothing has been sent. ${chat.choiceMessage} Then call ai_usage_upload with confirm:true only if they pick "${c.yes}" (adding exclude with the repos and agents they want left out, exactly as listed in excludable, and excerpts:true if they want no text excerpts), or confirm:false if they pick "${c.no}". Only the talent decides: never answer for them.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function upload(review, exclude) {
|
|
227
|
+
const c = reviewCopy(review.lang);
|
|
228
|
+
const session = activeSession();
|
|
229
|
+
if (!session) {
|
|
230
|
+
return { ok: false, reason: 'no-session', message: 'Nothing was sent: there is no signed-in Shakers session. Ask the talent to sign in (social_signin_start or `shakers login`), then call ai_usage_upload again.' };
|
|
231
|
+
}
|
|
232
|
+
let scan = review.scan;
|
|
233
|
+
if (exclude.repos.length > 0) {
|
|
234
|
+
const kept = (scan.repos || []).filter((r) => !exclude.repos.includes(r));
|
|
235
|
+
if (kept.length === 0) {
|
|
236
|
+
review.outcome = { ok: false, reason: 'nothing-left', relayVerbatim: c.nothingLeft, message: `Nothing was sent. ${VERBATIM}` };
|
|
237
|
+
return review.outcome;
|
|
134
238
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
let send;
|
|
139
|
-
try {
|
|
140
|
-
recordConsent('granted', session.email, { verified: true });
|
|
141
|
-
send = await autoShare(scan.report, scan.maturity, { root: current.root, bypassThrottle: true });
|
|
142
|
-
} catch (error) {
|
|
143
|
-
send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
|
|
239
|
+
const rescanned = await scanUsage(buildArgv({ root: review.root, repoScope: { mode: 'list', repos: kept }, lang: review.lang }));
|
|
240
|
+
if (!(rescanned.repos || []).every((r) => kept.includes(r))) {
|
|
241
|
+
return { ok: false, reason: 'scope-mismatch', relayVerbatim: c.uploadFailed, message: `Nothing was sent: the repositories left out could not be separated. ${VERBATIM}` };
|
|
144
242
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
243
|
+
scan = rescanned;
|
|
244
|
+
}
|
|
245
|
+
const trimmed = reviewTexts.withoutExcluded(scan.report, { agents: exclude.agents, excerpts: exclude.excerpts });
|
|
246
|
+
let send;
|
|
247
|
+
try {
|
|
248
|
+
recordConsent('granted', session.email, { verified: true });
|
|
249
|
+
send = await autoShare(trimmed.report, trimmed.maturity, { root: review.root, bypassThrottle: review.registration });
|
|
250
|
+
} catch (error) {
|
|
251
|
+
send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
|
|
252
|
+
}
|
|
253
|
+
if (!send || !send.ok) {
|
|
254
|
+
return { ok: false, reason: (send && send.reason) || 'upload-failed', send, relayVerbatim: c.uploadFailed, message: `Nothing was saved in Shakers. ${VERBATIM}` };
|
|
255
|
+
}
|
|
256
|
+
const sent = reviewTexts.summaryText(review.lang, reviewTexts.uploadFacts({ report: trimmed.report, maturity: trimmed.maturity, repos: scan.repos || [] }), { sent: true });
|
|
257
|
+
const excluded = exclude.repos.length > 0 || exclude.agents.length > 0 || !exclude.excerpts;
|
|
258
|
+
const confirmation = excluded ? [c.uploaded, sent] : [c.uploaded];
|
|
259
|
+
review.outcome = { ok: true, uploaded: true, send, sentReport: trimmed, sent };
|
|
260
|
+
if (review.registration) {
|
|
261
|
+
review.outcome.relayVerbatim = [...confirmation, c.afterwards].join('\n\n');
|
|
262
|
+
review.outcome.next = 'ai_usage_result';
|
|
263
|
+
review.outcome.message = `${VERBATIM} Then carry on with the sign-up; do not wait for the result.`;
|
|
264
|
+
return review.outcome;
|
|
265
|
+
}
|
|
266
|
+
return Object.assign(review.outcome, await afterUpload(review, trimmed, confirmation));
|
|
150
267
|
}
|
|
268
|
+
|
|
269
|
+
async function afterUpload(review, { report, maturity }, confirmation) {
|
|
270
|
+
const c = reviewCopy(review.lang);
|
|
271
|
+
const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
|
|
272
|
+
let enrichment = hasAgents ? 'pending' : 'none';
|
|
273
|
+
let enrichedAgents = null;
|
|
274
|
+
if (hasAgents) {
|
|
275
|
+
const session = loadAuthSession();
|
|
276
|
+
const endpoint = getUsageDiscoveredInventoryEndpoint();
|
|
277
|
+
if (sessionStatus(session) === 'active' && endpoint) {
|
|
278
|
+
const polled = await pollClassifiedInventory({ accessToken: session.accessToken, endpoint, requestDiscoveredInventory, capMs: inlineWaitMs });
|
|
279
|
+
if (polled.status === 'ready') {
|
|
280
|
+
enrichment = 'ready';
|
|
281
|
+
enrichedAgents = polled.agents;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
let aiProfile = { status: 'unavailable', preview: null };
|
|
286
|
+
const usageSession = loadAuthSession();
|
|
287
|
+
if (sessionStatus(usageSession) === 'active') {
|
|
288
|
+
aiProfile = await resolveAiProfilePreview(
|
|
289
|
+
{ session: usageSession, endpoint: getAiProfileEndpoint(), timeoutMs: aiFluencyWaitMs },
|
|
290
|
+
{
|
|
291
|
+
refreshToken: async () => {
|
|
292
|
+
const fresh = await ensureFreshSession(process.env);
|
|
293
|
+
return fresh ? { accessToken: fresh.accessToken, hubAccessToken: fresh.hubAccessToken } : null;
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
const displayAgents = enrichedAgents
|
|
299
|
+
? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
|
|
300
|
+
: (report.agents || []).map((a) => ({ name: a.name }));
|
|
301
|
+
const text = renderFullReportText(report, maturity, { status: aiProfile.status, preview: aiProfile.preview }, { lang: review.lang });
|
|
302
|
+
return {
|
|
303
|
+
report,
|
|
304
|
+
maturity,
|
|
305
|
+
enrichment,
|
|
306
|
+
agents: enrichedAgents,
|
|
307
|
+
text,
|
|
308
|
+
aiProfile,
|
|
309
|
+
relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null)].join('\n\n'),
|
|
310
|
+
next: enrichment === 'pending' || aiProfile.status !== 'ready' ? 'ai_usage_result' : null,
|
|
311
|
+
message: `Uploaded. ${VERBATIM} Then show \`text\`, the full report, verbatim.${enrichment === 'pending' || aiProfile.status !== 'ready' ? ' Shakers is still evaluating (normal, not an error): call ai_usage_result in a few minutes for the result and its explanation.' : ''}`,
|
|
312
|
+
display: {
|
|
313
|
+
tier: { key: maturity.tierKey, name: maturity.tierName, setupLevel: maturity.name, score: maturity.score },
|
|
314
|
+
aiProfile: {
|
|
315
|
+
status: aiProfile.status,
|
|
316
|
+
setup: aiProfile.preview ? aiProfile.preview.matrix.setupCode : null,
|
|
317
|
+
usage: aiProfile.preview ? aiProfile.preview.matrix.usageLevel : null,
|
|
318
|
+
cell: aiProfile.preview ? aiProfile.preview.matrix.cell : null,
|
|
319
|
+
isAiNative: aiProfile.preview ? aiProfile.preview.matrix.isAiNative : false,
|
|
320
|
+
vision: aiProfile.preview ? aiProfile.preview.vision : null,
|
|
321
|
+
howIWork: aiProfile.preview ? aiProfile.preview.howIWork : null,
|
|
322
|
+
traction: aiProfile.preview ? aiProfile.preview.traction : null,
|
|
323
|
+
},
|
|
324
|
+
tierLegend: tierLadder(),
|
|
325
|
+
agents: displayAgents,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
151
330
|
function declined(lang) {
|
|
152
331
|
recordConsent('denied');
|
|
153
332
|
if (signupPhase() === 'account') {
|
|
@@ -225,177 +404,117 @@ function makeAiUsageTool(deps = {}) {
|
|
|
225
404
|
const email = typeof consent.email === 'string' ? consent.email.trim() : '';
|
|
226
405
|
const argv = buildArgv({ root, repoScope, lang: args.lang });
|
|
227
406
|
|
|
228
|
-
// In the sign-up the scan never holds the conversation: it runs and uploads in the background.
|
|
229
407
|
if (phase === 'account') {
|
|
230
|
-
|
|
231
|
-
|
|
408
|
+
if (pending && pending.registration && pending.outcome && !pending.outcome.uploaded) pending = null;
|
|
409
|
+
startSignupScan(argv, root, textLang);
|
|
232
410
|
return {
|
|
233
411
|
ok: true,
|
|
234
412
|
background: true,
|
|
235
|
-
send: current.send || { ok: false, skipped: true, reason: 'uploading' },
|
|
236
413
|
scope: { mode, root },
|
|
237
|
-
message: 'The analysis runs
|
|
414
|
+
message: 'The analysis runs in the background and nothing is sent until the talent approves it. Say nothing about it. Next call ai_usage_upload (waitSeconds 30): it shows the talent what would be sent and asks. If it answers scan-running, carry on with the interview offer and call ai_usage_upload again right after the interview step.',
|
|
238
415
|
};
|
|
239
416
|
}
|
|
240
417
|
|
|
241
|
-
const
|
|
242
|
-
const emailVerifiedByLogin =
|
|
243
|
-
sessionStatus(verifySession) === 'active'
|
|
244
|
-
&& !!verifySession
|
|
245
|
-
&& isValidEmail(verifySession.email);
|
|
246
|
-
|
|
418
|
+
const session = activeSession();
|
|
247
419
|
if (!isValidEmail(email)) {
|
|
248
420
|
throw new Error('consent.granted is true but consent.email is missing or invalid — ask the talent for their email.');
|
|
249
421
|
}
|
|
250
|
-
if (
|
|
251
|
-
recordConsent('granted',
|
|
422
|
+
if (session) {
|
|
423
|
+
recordConsent('granted', session.email, { verified: true });
|
|
252
424
|
} else {
|
|
253
425
|
recordConsent('granted', email, { verified: false });
|
|
254
426
|
}
|
|
255
427
|
|
|
256
|
-
const
|
|
257
|
-
const submittedEmail = canSubmit ? verifySession.email : null;
|
|
258
|
-
|
|
259
|
-
const { report, maturity } = await scanUsage(argv);
|
|
260
|
-
|
|
428
|
+
const scan = await scanUsage(argv);
|
|
261
429
|
let savedLocally = false;
|
|
262
430
|
try {
|
|
263
|
-
persistFootprint({ root, report, maturity });
|
|
431
|
+
persistFootprint({ root, report: scan.report, maturity: scan.maturity });
|
|
264
432
|
savedLocally = true;
|
|
265
433
|
} catch {
|
|
266
434
|
savedLocally = false;
|
|
267
435
|
}
|
|
268
436
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
437
|
+
if (!session) {
|
|
438
|
+
const text = renderFullReportText(scan.report, scan.maturity, { status: 'unavailable', preview: null }, { lang });
|
|
439
|
+
return {
|
|
440
|
+
ok: true,
|
|
441
|
+
uploaded: false,
|
|
442
|
+
send: { ok: false, skipped: true, reason: 'email-unverified' },
|
|
443
|
+
savedLocally,
|
|
444
|
+
text,
|
|
445
|
+
scope: { mode, root },
|
|
446
|
+
message: "Not sent: the talent is not signed in to Shakers with a verified email. Show `text` verbatim; to put it on their profile they sign in (`shakers login` or social_signin_start) and run ai_usage again. Nothing was sent to Shakers.",
|
|
447
|
+
};
|
|
448
|
+
}
|
|
273
449
|
|
|
274
|
-
const
|
|
450
|
+
const review = openReview({ scan, root, lang: textLang, registration: false });
|
|
451
|
+
const reviewed = await presentReview(review, ctx);
|
|
452
|
+
return { ...reviewed, savedLocally, scope: { mode, root }, consent: { granted: true, email: session.email } };
|
|
453
|
+
}
|
|
275
454
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const endpoint = getUsageDiscoveredInventoryEndpoint();
|
|
281
|
-
if (sessionStatus(session) === 'active' && endpoint) {
|
|
282
|
-
const polled = await pollClassifiedInventory({
|
|
283
|
-
accessToken: session.accessToken,
|
|
284
|
-
endpoint,
|
|
285
|
-
requestDiscoveredInventory,
|
|
286
|
-
capMs: inlineWaitMs,
|
|
287
|
-
});
|
|
288
|
-
if (polled.status === 'ready') {
|
|
289
|
-
enrichment = 'ready';
|
|
290
|
-
enrichedAgents = polled.agents;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
455
|
+
async function waitForSignupScan(waitMs) {
|
|
456
|
+
if (job.state !== 'scanning') return;
|
|
457
|
+
await Promise.race([job.scan.catch(() => null), new Promise((resolve) => { setTimeout(resolve, waitMs).unref(); })]);
|
|
458
|
+
}
|
|
294
459
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
timeoutMs: aiFluencyWaitMs,
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
refreshToken: async () => {
|
|
308
|
-
const fresh = await ensureFreshSession(process.env);
|
|
309
|
-
return fresh ? { accessToken: fresh.accessToken, hubAccessToken: fresh.hubAccessToken } : null;
|
|
310
|
-
},
|
|
311
|
-
},
|
|
312
|
-
);
|
|
460
|
+
async function uploadHandler(args = {}, ctx = {}) {
|
|
461
|
+
if (!pending && job) {
|
|
462
|
+
const waitMs = Math.min(MAX_WAIT_MS, Math.max(0, Number(args.waitSeconds) || 0) * 1000);
|
|
463
|
+
await waitForSignupScan(waitMs);
|
|
464
|
+
if (job.state === 'scanning') {
|
|
465
|
+
return { ok: false, reason: 'scan-running', message: 'The analysis is still running; nothing has been sent. Say nothing about it: carry on with the interview offer and call ai_usage_upload again right after the interview step, before list_my_roles.' };
|
|
466
|
+
}
|
|
467
|
+
if (job.state === 'failed') {
|
|
468
|
+
return { ok: false, reason: 'scan-failed', message: 'The analysis could not finish; nothing was sent. Say nothing about it and carry on with the sign-up.' };
|
|
313
469
|
}
|
|
470
|
+
openReview({ scan: await job.scan, root: job.root, lang: job.lang, registration: true });
|
|
314
471
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
|
|
318
|
-
: (report.agents || []).map((a) => ({ name: a.name }));
|
|
319
|
-
|
|
320
|
-
// Same full report the CLI terminal prints, as plain markdown (no ANSI), so a chat client shows the talent the same thing.
|
|
321
|
-
const text = renderFullReportText(
|
|
322
|
-
report,
|
|
323
|
-
maturity,
|
|
324
|
-
{ status: aiProfile.status, preview: aiProfile.preview },
|
|
325
|
-
{ lang },
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
let message;
|
|
329
|
-
if (submitted) {
|
|
330
|
-
message =
|
|
331
|
-
enrichment === 'ready'
|
|
332
|
-
? 'Classification complete — agent categories and descriptions are ready.'
|
|
333
|
-
: enrichment === 'pending'
|
|
334
|
-
? 'Report submitted. Shakers is classifying the agents server-side (this is normal, not an error) — call ai_usage_result shortly to get their categories and descriptions.'
|
|
335
|
-
: 'Report submitted.';
|
|
336
|
-
} else if (send.reason === 'email-unverified') {
|
|
337
|
-
message = "Not submitted — the talent's email is not verified. Ask them to log in / verify with the Shakers CLI (`shakers login`) first, then run ai_usage again; nothing was sent to Shakers.";
|
|
338
|
-
} else if (send.skipped) {
|
|
339
|
-
message = `Not submitted (${send.reason}). The report was kept on this machine only; nothing was sent to Shakers.`;
|
|
340
|
-
} else {
|
|
341
|
-
message = `Submit failed (${send.reason}). The report was kept on this machine only; retry later.`;
|
|
472
|
+
if (!pending) {
|
|
473
|
+
return { ok: false, reason: 'nothing-to-review', message: 'There is no analysis waiting for approval. Call ai_usage first.' };
|
|
342
474
|
}
|
|
343
|
-
|
|
344
|
-
return
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
aiProfile,
|
|
355
|
-
next: submitted && enrichment === 'pending' ? 'ai_usage_result' : null,
|
|
356
|
-
message,
|
|
357
|
-
display: {
|
|
358
|
-
tier: {
|
|
359
|
-
key: maturity.tierKey,
|
|
360
|
-
name: maturity.tierName,
|
|
361
|
-
setupLevel: maturity.name,
|
|
362
|
-
score: maturity.score,
|
|
363
|
-
},
|
|
364
|
-
// Matrix (3x3) + vision (E) + howIWork (F) + traction; null until projected.
|
|
365
|
-
aiProfile: {
|
|
366
|
-
status: aiProfile.status,
|
|
367
|
-
setup: aiProfile.preview ? aiProfile.preview.matrix.setupCode : null,
|
|
368
|
-
usage: aiProfile.preview ? aiProfile.preview.matrix.usageLevel : null,
|
|
369
|
-
cell: aiProfile.preview ? aiProfile.preview.matrix.cell : null,
|
|
370
|
-
isAiNative: aiProfile.preview ? aiProfile.preview.matrix.isAiNative : false,
|
|
371
|
-
vision: aiProfile.preview ? aiProfile.preview.vision : null,
|
|
372
|
-
howIWork: aiProfile.preview ? aiProfile.preview.howIWork : null,
|
|
373
|
-
traction: aiProfile.preview ? aiProfile.preview.traction : null,
|
|
374
|
-
},
|
|
375
|
-
tierLegend: tierLadder(),
|
|
376
|
-
agents: displayAgents,
|
|
377
|
-
},
|
|
378
|
-
scope: {
|
|
379
|
-
mode,
|
|
380
|
-
root,
|
|
381
|
-
},
|
|
382
|
-
consent: { granted: true, email: canSubmit ? submittedEmail : email },
|
|
383
|
-
};
|
|
475
|
+
if (pending.outcome) return pending.outcome.uploaded ? { ...pending.outcome, message: 'Already uploaded; it is not sent twice.' } : pending.outcome;
|
|
476
|
+
if (pending.askedInChat && args.confirm === false) return declineUpload(pending);
|
|
477
|
+
if (pending.askedInChat && args.confirm === true) {
|
|
478
|
+
const exclude = exclusionsFromChat(pending, args.exclude);
|
|
479
|
+
if (exclude.unknown.length > 0) {
|
|
480
|
+
const facts = reviewFacts(pending);
|
|
481
|
+
return { ok: false, reason: 'unknown-exclusion', unknown: exclude.unknown, excludable: { repos: facts.repos, agents: facts.agents }, message: `Nothing was sent: ${exclude.unknown.join(', ')} is not in excludable. Ask the talent which of the listed names they meant, then call ai_usage_upload again with those exact names.` };
|
|
482
|
+
}
|
|
483
|
+
return upload(pending, exclude);
|
|
484
|
+
}
|
|
485
|
+
return presentReview(pending, ctx);
|
|
384
486
|
}
|
|
385
487
|
|
|
386
|
-
|
|
488
|
+
const usage = {
|
|
387
489
|
name: 'ai_usage',
|
|
388
490
|
title: 'Analyse how you work with AI',
|
|
389
|
-
description: 'Asks you first and, only if you say yes, looks at which AI tools you use on this computer and the history of your code projects.
|
|
491
|
+
description: 'Asks you first and, only if you say yes, looks at which AI tools you use on this computer and the history of your code projects. Then it shows you exactly what would go to your Shakers profile and uploads it only if you approve it; your code and prompts never leave your computer.',
|
|
390
492
|
guidance:
|
|
391
|
-
"Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity)
|
|
493
|
+
"Measure how a Shakers talent uses AI in their work: scans this machine and repository for AI-tool usage (subagents, MCP servers, sessions, git activity) and scores their AI-maturity tier (T0-T7). Runs entirely on the talent's own machine. Before any scan, call it without consent: it asks the AI-usage question with both disclaimers in the same block (a dialog, or `say` to print word for word); only an explicit yes allows consent.granted:true, and skipping is consent.granted:false. The yes only scans: nothing is uploaded until the talent approves the review. Outside the sign-up, with a signed-in, email-verified session, the scan is followed by that review in the same call: a form when the client has one, otherwise `say` (what would be sent, what Shakers does with it, and the question) to print word for word, and then ai_usage_upload with the talent's answer. Without a verified session nothing can be sent and `text` is the report only. In the sign-up it is the step right after the account exists: on yes the scan runs in the background, then ai_usage_upload asks. After an upload, show relayVerbatim and `text` (the 'My work with AI' report, Markdown, no ANSI; keep the matrix grid fenced) verbatim; if next is ai_usage_result, call it in a few minutes. It runs no LLM on the machine. IDs and codes (agent `code`, skillId, catalogId) are internal handles for the add_* tools only — never show them.",
|
|
392
494
|
inputSchema: AI_USAGE_INPUT_SCHEMA,
|
|
393
495
|
handler,
|
|
394
496
|
};
|
|
497
|
+
|
|
498
|
+
const uploadTool = {
|
|
499
|
+
name: 'ai_usage_upload',
|
|
500
|
+
title: 'Review and upload your AI analysis',
|
|
501
|
+
description: 'Shows you exactly what your AI analysis would send to your Shakers profile and what Shakers does with it, lets you leave out repositories, agents or text excerpts, and uploads it only if you approve.',
|
|
502
|
+
guidance:
|
|
503
|
+
"The only way an AI-usage analysis reaches Shakers. Call it without confirm: it shows the review (a form the talent fills in when the client has one; otherwise `say` to print word for word, plus `excludable`). In the chat, call it again with confirm:true only when the talent picked the upload option, with exclude for what they asked to leave out, or confirm:false when they picked not to; never decide for them, and confirm counts only after the review was shown in the chat. In the sign-up pass waitSeconds 30: reason scan-running means carry on and call it again after the interview step. On an upload print relayVerbatim word for word. Nothing is sent twice.",
|
|
504
|
+
inputSchema: UPLOAD_INPUT_SCHEMA,
|
|
505
|
+
handler: uploadHandler,
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
return { usage, upload: uploadTool };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function makeAiUsageTool(deps = {}) {
|
|
512
|
+
return makeAiUsageTools(deps).usage;
|
|
395
513
|
}
|
|
396
514
|
|
|
397
515
|
module.exports = {
|
|
398
516
|
makeAiUsageTool,
|
|
517
|
+
makeAiUsageTools,
|
|
399
518
|
AI_USAGE_INPUT_SCHEMA,
|
|
400
519
|
buildArgv,
|
|
401
520
|
};
|
package/src/mcp-auth-form.js
CHANGED
|
@@ -243,8 +243,9 @@ const LINKEDIN_IN = '<svg class="g" viewBox="0 0 24 24" aria-hidden="true">'
|
|
|
243
243
|
|
|
244
244
|
// Each window offers these methods, in this order. The sign-up window has no LinkedIn: only Google carries the claim code there.
|
|
245
245
|
const WINDOW_METHODS = { login: ['google', 'linkedin', 'email'], register: ['google', 'email'] };
|
|
246
|
+
const FORM_MISTAKES = new Set(['legal-not-shown', 'missing-fields', 'password-mismatch', 'weak-password']);
|
|
246
247
|
|
|
247
|
-
// The method-choice screen
|
|
248
|
+
// The method-choice screen — the FIRST screen of the loopback form when more than one method is offered for this flow.
|
|
248
249
|
function methodChoiceHtml(mode, lang, methods) {
|
|
249
250
|
const c = copy(lang);
|
|
250
251
|
const title = mode === 'register' ? c.chooseTitleRegister : c.chooseTitleLogin;
|
|
@@ -364,12 +365,13 @@ function runLoopbackAuth(
|
|
|
364
365
|
const showMethodScreen = !method && methods.length > 1;
|
|
365
366
|
return new Promise((resolve) => {
|
|
366
367
|
let settled = false;
|
|
368
|
+
let failedAttempts = 0;
|
|
367
369
|
const finish = (result) => {
|
|
368
370
|
if (settled) return;
|
|
369
371
|
settled = true;
|
|
370
372
|
clearTimeout(timer);
|
|
371
373
|
try { server.close(); } catch { /* already closing */ }
|
|
372
|
-
resolve(result);
|
|
374
|
+
resolve(result && result.ok && mode === 'register' ? { ...result, attempts: failedAttempts + 1 } : result);
|
|
373
375
|
};
|
|
374
376
|
|
|
375
377
|
// Any page in the talent's browser can reach a loopback port, and a rebound DNS name can read it: only this
|
|
@@ -426,6 +428,7 @@ function runLoopbackAuth(
|
|
|
426
428
|
if (req.method === 'GET' && url.pathname === '/google' && methods.includes('google') && typeof onGoogle === 'function') {
|
|
427
429
|
const target = await onGoogle();
|
|
428
430
|
if (!target || !target.ok || !target.url) {
|
|
431
|
+
failedAttempts += 1;
|
|
429
432
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
430
433
|
res.end(formHtml(mode, lang, { ...formOpts, error: (target && target.reason) || 'google-unavailable' }));
|
|
431
434
|
return;
|
|
@@ -455,6 +458,7 @@ function runLoopbackAuth(
|
|
|
455
458
|
res.end(successHtml(lang));
|
|
456
459
|
finish(outcome);
|
|
457
460
|
} else {
|
|
461
|
+
if (outcome.reason && !FORM_MISTAKES.has(outcome.reason)) failedAttempts += 1;
|
|
458
462
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
459
463
|
res.end(outcome.existing
|
|
460
464
|
? existingAccountHtml(lang, { email: outcome.email, error: outcome.reason })
|
|
@@ -477,4 +481,4 @@ function runLoopbackAuth(
|
|
|
477
481
|
});
|
|
478
482
|
}
|
|
479
483
|
|
|
480
|
-
module.exports = { runLoopbackAuth, doAuth, loginFormHtml, registerFormHtml, existingAccountHtml, methodChoiceHtml, socialHandoffHtml, DEFAULT_TIMEOUT_MS };
|
|
484
|
+
module.exports = { runLoopbackAuth, WINDOW_METHODS, doAuth, loginFormHtml, registerFormHtml, existingAccountHtml, methodChoiceHtml, socialHandoffHtml, DEFAULT_TIMEOUT_MS };
|
package/src/mcp-detector.js
CHANGED
|
@@ -4,7 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { getHomeDir } = require('./env-paths');
|
|
6
6
|
|
|
7
|
-
// KEYWORD -> { SERVICE LABEL, CATEGORY }
|
|
7
|
+
// KEYWORD -> { SERVICE LABEL, CATEGORY }.
|
|
8
8
|
const SERVICE_CATALOG = [
|
|
9
9
|
// --- data ---
|
|
10
10
|
{ kw: 'postgresql', label: 'Postgres', category: 'data' },
|
|
@@ -87,7 +87,7 @@ function serviceForMcpServerName(name) {
|
|
|
87
87
|
return hit && hit.label ? hit.label : null;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// THE single inventory of MCP config locations
|
|
90
|
+
// THE single inventory of MCP config locations.
|
|
91
91
|
const MCP_CONFIG_LOCATIONS = [
|
|
92
92
|
// --- Claude Code (already read before 112) ---
|
|
93
93
|
{ tool: 'claude-code', scope: 'project', keys: ['mcpServers'], confidence: 'high', file: (root) => path.join(root, '.mcp.json') },
|
|
@@ -181,7 +181,7 @@ function detectMcpServers(root) {
|
|
|
181
181
|
// 'project' wins when a server is declared in both: the most specific
|
|
182
182
|
// scope is the honest label for "this repo uses it".
|
|
183
183
|
scope: entry.scopes.has('project') ? 'project' : 'home',
|
|
184
|
-
//
|
|
184
|
+
// The PRODUCT, derived from the name only. `null` when the
|
|
185
185
|
// name carries none — a state, not a hole.
|
|
186
186
|
service: serviceForMcpServerName(name),
|
|
187
187
|
};
|
|
@@ -24,6 +24,7 @@ function makeDeviceLoginTools(deps = {}) {
|
|
|
24
24
|
pollDeviceToken = require('./device-login').pollDeviceToken,
|
|
25
25
|
finalizeDeviceSession = require('./device-login').finalizeDeviceSession,
|
|
26
26
|
appendProviderParam = require('./device-login').appendProviderParam,
|
|
27
|
+
deviceSessionRecord = require('./device-login').deviceSessionRecord,
|
|
27
28
|
savePendingDevice = require('./device-auth-store').savePendingDevice,
|
|
28
29
|
loadPendingDevice = require('./device-auth-store').loadPendingDevice,
|
|
29
30
|
clearPendingDevice = require('./device-auth-store').clearPendingDevice,
|
|
@@ -144,7 +145,7 @@ function makeDeviceLoginTools(deps = {}) {
|
|
|
144
145
|
return { ok: false, status: 'error', reason: fin.reason };
|
|
145
146
|
}
|
|
146
147
|
try {
|
|
147
|
-
saveAuthSession({
|
|
148
|
+
saveAuthSession(deviceSessionRecord({ token: fin.token, sessionToken: res.accessToken, sessionExpiresIn: res.expiresIn, email: res.email || pending.email || null }));
|
|
148
149
|
} catch {
|
|
149
150
|
clearPendingDevice();
|
|
150
151
|
return { ok: false, status: 'error', reason: 'session-persist-failed' };
|