shakers-cli 0.16.0 → 0.18.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 +21 -6
- package/bin/ai-usage.js +31 -12
- package/bin/command-audit-worker.js +19 -0
- package/bin/login.js +4 -8
- package/bin/mcp.js +32 -21
- package/bin/register.js +12 -30
- package/bin/shakers-cli.js +15 -10
- package/package.json +1 -1
- package/src/account-marker.js +40 -0
- package/src/account-trace.js +23 -0
- package/src/agents-client.js +1 -0
- package/src/ai-usage-review.js +103 -0
- package/src/auth-client.js +7 -107
- package/src/auth-session-store.js +12 -11
- package/src/backend-request.js +3 -24
- package/src/cli-skill.js +77 -0
- package/src/command-audit.js +47 -4
- package/src/config.js +2 -16
- package/src/consent-flow.js +9 -3
- package/src/detect-repos.js +4 -8
- package/src/detectors.js +23 -0
- package/src/device-auth-store.js +2 -2
- package/src/device-login.js +11 -9
- package/src/env-paths.js +9 -1
- package/src/i18n.js +186 -102
- package/src/mcp-ai-usage-result-tool.js +38 -14
- package/src/mcp-ai-usage-tool.js +317 -172
- package/src/mcp-choice.js +171 -34
- package/src/mcp-detector.js +26 -2
- package/src/mcp-device-login-tools.js +40 -8
- package/src/mcp-install.js +292 -53
- package/src/mcp-instructions.js +43 -17
- package/src/mcp-onboarding-tools.js +1 -3
- package/src/mcp-prompts.js +8 -6
- package/src/mcp-register-tools.js +25 -14
- package/src/mcp-role-tools.js +59 -18
- package/src/mcp-server-name.js +7 -0
- package/src/mcp-server.js +35 -7
- package/src/mcp-signup-client.js +3 -6
- package/src/mcp-signup-flow.js +389 -0
- package/src/mcp-signup-tools.js +250 -133
- package/src/onboarding-flow.js +72 -146
- package/src/onboarding-funnel.js +46 -6
- package/src/opencode-sessions.js +92 -0
- package/src/profile-agents.js +26 -0
- package/src/render-report-text.js +1 -1
- package/src/report-gating.js +4 -2
- package/src/report-store.js +7 -2
- package/src/roles-flow.js +8 -5
- package/src/saved-positions-flow.js +3 -3
- package/src/scanner.js +104 -12
- package/src/session-gate.js +1 -4
- package/src/session-normalizers.js +114 -8
- package/src/session-refresh.js +5 -16
- package/src/session-scan.js +167 -22
- package/src/share-consent-state.js +2 -2
- package/src/share-payload.js +4 -1
- package/src/share.js +2 -2
- package/src/signup-client.js +12 -39
- package/src/signup-copy.js +545 -58
- package/src/skill-install.js +102 -0
- package/src/stdin-ask.js +13 -1
- package/src/tier-engine.js +14 -2
- package/src/trust-narration.js +32 -0
- package/src/usage-run-steps.js +68 -6
- package/src/usage-scan.js +11 -8
- package/src/brand-svg.js +0 -9
- package/src/inter-fonts.js +0 -6
- package/src/mcp-auth-form.js +0 -480
package/src/mcp-ai-usage-tool.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const { execFileSync } = require('child_process');
|
|
5
5
|
const { isRefusedWalkRoot } = require('./scan-exclusions');
|
|
6
|
-
const { askChoice, chatChoice, queueNotice } = require('./mcp-choice');
|
|
6
|
+
const { askChoice, chatChoice, forChat, handOver, narrationOf, queueNotice, settle, withNextSay } = require('./mcp-choice');
|
|
7
7
|
|
|
8
8
|
function isExistingDir(p) {
|
|
9
9
|
try {
|
|
@@ -75,11 +75,38 @@ 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
|
+
const CONSENT_QUESTION = 'ai_usage:consent';
|
|
80
|
+
|
|
81
|
+
const UPLOAD_INPUT_SCHEMA = {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
confirm: {
|
|
85
|
+
type: 'boolean',
|
|
86
|
+
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.',
|
|
87
|
+
},
|
|
88
|
+
exclude: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
description: 'What the talent asked to leave out, with confirm:true. Names exactly as listed in `excludable`.',
|
|
91
|
+
properties: {
|
|
92
|
+
repos: { type: 'array', items: { type: 'string' } },
|
|
93
|
+
agents: { type: 'array', items: { type: 'string' } },
|
|
94
|
+
excerpts: { type: 'boolean', description: 'true = send no text excerpts.' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
waitSeconds: { type: 'number', description: 'Sign-up only: how long to wait for the background analysis before answering (max 45).' },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const MAX_WAIT_MS = 45000;
|
|
102
|
+
|
|
103
|
+
function makeAiUsageTools(deps = {}) {
|
|
79
104
|
const {
|
|
80
105
|
scanUsage = (argv) => require('./usage-scan').scanUsage(argv),
|
|
81
106
|
autoShare = require('./share').autoShare,
|
|
107
|
+
logCommandRun = require('./command-audit').logCommandRun,
|
|
82
108
|
recordConsent = require('./share').recordConsent,
|
|
109
|
+
sendThrottled = () => require('./share').isThrottled(require('./share').loadConsentState()),
|
|
83
110
|
isValidEmail = require('./share').isValidEmail,
|
|
84
111
|
persistFootprint = require('./report-store').persistFootprint,
|
|
85
112
|
cwdIsRepo = defaultCwdIsRepo,
|
|
@@ -90,64 +117,221 @@ function makeAiUsageTool(deps = {}) {
|
|
|
90
117
|
pollClassifiedInventory = require('./mcp-ai-usage-result-tool').pollClassifiedInventory,
|
|
91
118
|
tierLadder = require('./tier-engine').tierLadder,
|
|
92
119
|
inlineWaitMs = 35000,
|
|
93
|
-
// "My work with AI" preview, from the same hub endpoint the front paints.
|
|
94
120
|
resolveAiProfilePreview = require('./ai-profile-preview').resolveAiProfilePreview,
|
|
95
121
|
ensureFreshSession = require('./session-refresh').ensureFreshSession,
|
|
96
122
|
getAiProfileEndpoint = require('./config').getAiProfileEndpoint,
|
|
97
|
-
// Plain-text render of the full report (terminal sections + matrix/vision/howIWork), shared with the CLI (no ANSI).
|
|
98
123
|
renderFullReportText = require('./render-report-text').renderFullReportText,
|
|
99
124
|
detectFlowLang = require('./i18n').detectFlowLang,
|
|
100
125
|
aiFluencyWaitMs = 30000,
|
|
126
|
+
signupInventoryWaitMs = 20000,
|
|
127
|
+
signupProfileWaitMs = 25000,
|
|
101
128
|
signupPhase = () => require('./mcp-signup-tools').signupPhase(),
|
|
102
129
|
usageDisclaimers = (lang) => require('./onboarding-flow').usageDisclaimers(lang),
|
|
103
130
|
signupCopy = require('./signup-copy').signupCopy,
|
|
131
|
+
reviewCopy = require('./signup-copy').reviewCopy,
|
|
104
132
|
signupLanguage = require('./signup-language').signupLanguage,
|
|
133
|
+
reviewTexts = require('./ai-usage-review'),
|
|
105
134
|
} = deps;
|
|
106
135
|
|
|
107
|
-
// The sign-up's one scan: started on the talent's yes once the account exists, uploaded in the background.
|
|
108
136
|
let job = null;
|
|
109
137
|
// The talent's answer in the consent dialog, so a later call cannot contradict it.
|
|
110
138
|
let dialogConsent = null;
|
|
139
|
+
// In the sign-up a consent counts only after its question and the two disclaimers went to the chat.
|
|
140
|
+
let consentAsked = false;
|
|
141
|
+
let pending = null;
|
|
111
142
|
|
|
112
|
-
function startSignupScan(argv, root) {
|
|
113
|
-
if (job) return job;
|
|
114
|
-
job = { state: 'scanning',
|
|
143
|
+
function startSignupScan(argv, root, lang) {
|
|
144
|
+
if (job && job.state !== 'failed') return job;
|
|
145
|
+
job = { state: 'scanning', root, lang };
|
|
115
146
|
// performScan is synchronous: start it after this answer has gone out.
|
|
116
147
|
job.scan = new Promise((resolve) => { setImmediate(resolve); }).then(() => scanUsage(argv)).then((scan) => {
|
|
117
148
|
try { persistFootprint({ root, report: scan.report, maturity: scan.maturity }); } catch { /* local copy is best-effort */ }
|
|
149
|
+
job.state = 'scanned';
|
|
118
150
|
return scan;
|
|
119
151
|
});
|
|
120
152
|
job.scan.catch(() => { job.state = 'failed'; });
|
|
121
|
-
job.root = root;
|
|
122
153
|
return job;
|
|
123
154
|
}
|
|
124
155
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
function activeSession() {
|
|
157
|
+
const session = loadAuthSession();
|
|
158
|
+
return sessionStatus(session) === 'active' && session && isValidEmail(session.email) ? session : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function openReview({ scan, root, lang, registration }) {
|
|
162
|
+
pending = { scan, root, lang, registration, askedInChat: false, outcome: null };
|
|
163
|
+
return pending;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function reviewFacts(review) {
|
|
167
|
+
return reviewTexts.uploadFacts({ report: review.scan.report, maturity: review.scan.maturity, repos: review.scan.repos || [] });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function reviewSchema(c, facts) {
|
|
171
|
+
const properties = { decision: { type: 'string', title: c.question, enum: [c.yes, c.no] } };
|
|
172
|
+
if (facts.conversationExcerpts + facts.commandExcerpts > 0) properties.excerpts = { type: 'boolean', title: c.formExcerpts, default: true };
|
|
173
|
+
if (facts.repos.length > 1) facts.repos.forEach((name, i) => { properties[`repo_${i}`] = { type: 'boolean', title: c.formRepo(name), default: true }; });
|
|
174
|
+
facts.agents.forEach((name, i) => { properties[`agent_${i}`] = { type: 'boolean', title: c.formAgent(name), default: true }; });
|
|
175
|
+
return { type: 'object', properties, required: ['decision'] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function exclusionsFromForm(review, facts, content) {
|
|
179
|
+
const repos = review.scan.repos || [];
|
|
180
|
+
return {
|
|
181
|
+
repos: repos.filter((_, i) => content[`repo_${i}`] === false),
|
|
182
|
+
agents: facts.agents.filter((_, i) => content[`agent_${i}`] === false),
|
|
183
|
+
excerpts: content.excerpts !== false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function exclusionsFromChat(review, exclude) {
|
|
188
|
+
const e = exclude && typeof exclude === 'object' ? exclude : {};
|
|
189
|
+
const names = new Set((Array.isArray(e.repos) ? e.repos : []).map(String));
|
|
190
|
+
const known = review.scan.repos || [];
|
|
191
|
+
const repos = known.filter((r) => names.has(r) || names.has(reviewTexts.repoName(r)));
|
|
192
|
+
const agentNames = reviewFacts(review).agents;
|
|
193
|
+
const agents = Array.isArray(e.agents) ? e.agents.map(String) : [];
|
|
194
|
+
const unknown = [
|
|
195
|
+
...[...names].filter((n) => !known.some((r) => r === n || reviewTexts.repoName(r) === n)),
|
|
196
|
+
...agents.filter((a) => !agentNames.includes(a)),
|
|
197
|
+
];
|
|
198
|
+
return { repos, agents, excerpts: e.excerpts !== true, unknown };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function declineUpload(review) {
|
|
202
|
+
const c = reviewCopy(review.lang);
|
|
203
|
+
review.outcome = { ok: false, reason: 'upload-declined', relayVerbatim: c.declined, message: `Nothing was sent. ${VERBATIM} Then carry on.` };
|
|
204
|
+
return review.outcome;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function presentReview(review, ctx) {
|
|
208
|
+
const c = reviewCopy(review.lang);
|
|
209
|
+
if (!review.registration && sendThrottled()) {
|
|
210
|
+
return { ok: false, reason: 'throttled', relayVerbatim: c.throttled, message: `Nothing was sent. ${VERBATIM}` };
|
|
211
|
+
}
|
|
212
|
+
const facts = reviewFacts(review);
|
|
213
|
+
const texts = [reviewTexts.summaryText(review.lang, facts), reviewTexts.explanationText(review.lang)];
|
|
214
|
+
if (ctx && ctx.elicitation && typeof ctx.elicit === 'function') {
|
|
215
|
+
const reply = await ctx.elicit({ message: [...narrationOf(ctx), ...texts, c.question].join('\n\n'), requestedSchema: reviewSchema(c, facts) });
|
|
216
|
+
const content = reply && reply.action === 'accept' && reply.content ? reply.content : null;
|
|
217
|
+
if (content && content.decision === c.yes) return upload(review, exclusionsFromForm(review, facts, content));
|
|
218
|
+
if ((content && content.decision === c.no) || (reply && reply.action === 'decline')) return declineUpload(review);
|
|
219
|
+
}
|
|
220
|
+
review.askedInChat = true;
|
|
221
|
+
const { choiceMessage, ...shown } = chatChoice(c.question, [c.yes, c.no], [...texts, c.excludeHint]);
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
reason: 'upload-approval-required',
|
|
225
|
+
...shown,
|
|
226
|
+
excludable: { repos: facts.repos, agents: facts.agents, excerpts: facts.conversationExcerpts + facts.commandExcerpts > 0 },
|
|
227
|
+
message: `Nothing has been sent. ${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.`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function upload(review, exclude) {
|
|
232
|
+
const c = reviewCopy(review.lang);
|
|
233
|
+
const session = activeSession();
|
|
234
|
+
if (!session) {
|
|
235
|
+
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.' };
|
|
236
|
+
}
|
|
237
|
+
let scan = review.scan;
|
|
238
|
+
if (exclude.repos.length > 0) {
|
|
239
|
+
const kept = (scan.repos || []).filter((r) => !exclude.repos.includes(r));
|
|
240
|
+
if (kept.length === 0) {
|
|
241
|
+
review.outcome = { ok: false, reason: 'nothing-left', relayVerbatim: c.nothingLeft, message: `Nothing was sent. ${VERBATIM}` };
|
|
242
|
+
return review.outcome;
|
|
134
243
|
}
|
|
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) };
|
|
244
|
+
const rescanned = await scanUsage(buildArgv({ root: review.root, repoScope: { mode: 'list', repos: kept }, lang: review.lang }));
|
|
245
|
+
if (!(rescanned.repos || []).every((r) => kept.includes(r))) {
|
|
246
|
+
return { ok: false, reason: 'scope-mismatch', relayVerbatim: c.uploadFailed, message: `Nothing was sent: the repositories left out could not be separated. ${VERBATIM}` };
|
|
144
247
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
248
|
+
scan = rescanned;
|
|
249
|
+
}
|
|
250
|
+
const trimmed = reviewTexts.withoutExcluded(scan.report, { agents: exclude.agents, excerpts: exclude.excerpts });
|
|
251
|
+
let send;
|
|
252
|
+
try {
|
|
253
|
+
recordConsent('granted', session.email, { verified: true });
|
|
254
|
+
const cliCommandId = await logCommandRun({ command: 'mcp:ai_usage_upload:send' });
|
|
255
|
+
send = await autoShare(trimmed.report, trimmed.maturity, { root: review.root, bypassThrottle: review.registration, cliCommandId });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
send = { ok: false, reason: 'upload-failed', message: error && error.message ? error.message : String(error) };
|
|
258
|
+
}
|
|
259
|
+
if (!send || !send.ok) {
|
|
260
|
+
return { ok: false, reason: (send && send.reason) || 'upload-failed', send, relayVerbatim: c.uploadFailed, message: `Nothing was saved in Shakers. ${VERBATIM}` };
|
|
261
|
+
}
|
|
262
|
+
const sent = reviewTexts.summaryText(review.lang, reviewTexts.uploadFacts({ report: trimmed.report, maturity: trimmed.maturity, repos: scan.repos || [] }), { sent: true });
|
|
263
|
+
const excluded = exclude.repos.length > 0 || exclude.agents.length > 0 || !exclude.excerpts;
|
|
264
|
+
const confirmation = excluded ? [c.uploaded, sent] : [c.uploaded];
|
|
265
|
+
review.outcome = { ok: true, uploaded: true, send, sentReport: trimmed, sent };
|
|
266
|
+
if (!review.registration) return Object.assign(review.outcome, await afterUpload(review, trimmed, confirmation));
|
|
267
|
+
// The sign-up shows the same result as outside it, at the same moment, on the next question: the review already
|
|
268
|
+
// explained the two axes and how to disagree, so the result does not repeat them.
|
|
269
|
+
const result = await afterUpload(review, trimmed, confirmation, { inventoryMs: signupInventoryWaitMs, profileMs: signupProfileWaitMs, explained: true });
|
|
270
|
+
queueNotice([result.relayVerbatim, result.text].filter(Boolean).join('\n\n'));
|
|
271
|
+
const { relayVerbatim: _shown, message: _rule, ...rest } = result;
|
|
272
|
+
return Object.assign(review.outcome, rest, {
|
|
273
|
+
message: `Uploaded. Say nothing about it: the result of the analysis, which the talent reads word for word, comes with the next question.${result.next ? ' Shakers is still evaluating part of it (normal, not an error): do not wait for it.' : ''}`,
|
|
274
|
+
});
|
|
150
275
|
}
|
|
276
|
+
|
|
277
|
+
// The classified agents and the profile result are awaited together, so the answer stays under the client's cut.
|
|
278
|
+
async function afterUpload(review, { report, maturity }, confirmation, { inventoryMs = inlineWaitMs, profileMs = aiFluencyWaitMs, explained = false } = {}) {
|
|
279
|
+
const hasAgents = Array.isArray(report.agents) && report.agents.length > 0;
|
|
280
|
+
const classify = async () => {
|
|
281
|
+
if (!hasAgents) return null;
|
|
282
|
+
const session = loadAuthSession();
|
|
283
|
+
const endpoint = getUsageDiscoveredInventoryEndpoint();
|
|
284
|
+
if (sessionStatus(session) !== 'active' || !endpoint) return null;
|
|
285
|
+
return pollClassifiedInventory({ accessToken: session.accessToken, endpoint, requestDiscoveredInventory, capMs: inventoryMs });
|
|
286
|
+
};
|
|
287
|
+
const profile = async () => {
|
|
288
|
+
const usageSession = loadAuthSession();
|
|
289
|
+
if (sessionStatus(usageSession) !== 'active') return { status: 'unavailable', preview: null };
|
|
290
|
+
return resolveAiProfilePreview(
|
|
291
|
+
{ session: usageSession, endpoint: getAiProfileEndpoint(), timeoutMs: profileMs },
|
|
292
|
+
{
|
|
293
|
+
refreshToken: async () => {
|
|
294
|
+
const fresh = await ensureFreshSession(process.env);
|
|
295
|
+
return fresh ? { accessToken: fresh.accessToken, hubAccessToken: fresh.hubAccessToken } : null;
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
const [polled, aiProfile] = await Promise.all([classify(), profile()]);
|
|
301
|
+
const enrichedAgents = polled && polled.status === 'ready' ? polled.agents : null;
|
|
302
|
+
const enrichment = !hasAgents ? 'none' : enrichedAgents ? 'ready' : 'pending';
|
|
303
|
+
const displayAgents = enrichedAgents
|
|
304
|
+
? enrichedAgents.map((a) => ({ name: a.name, category: a.category, role: a.role }))
|
|
305
|
+
: (report.agents || []).map((a) => ({ name: a.name }));
|
|
306
|
+
const text = renderFullReportText(report, maturity, { status: aiProfile.status, preview: aiProfile.preview }, { lang: review.lang });
|
|
307
|
+
return {
|
|
308
|
+
report,
|
|
309
|
+
maturity,
|
|
310
|
+
enrichment,
|
|
311
|
+
agents: enrichedAgents,
|
|
312
|
+
text,
|
|
313
|
+
aiProfile,
|
|
314
|
+
relayVerbatim: [...confirmation, reviewTexts.resultText(review.lang, aiProfile.status === 'ready' ? aiProfile.preview : null, { explained })].join('\n\n'),
|
|
315
|
+
next: enrichment === 'pending' || aiProfile.status !== 'ready' ? 'ai_usage_result' : null,
|
|
316
|
+
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.' : ''}`,
|
|
317
|
+
display: {
|
|
318
|
+
tier: { key: maturity.tierKey, name: maturity.tierName, setupLevel: maturity.name, score: maturity.score },
|
|
319
|
+
aiProfile: {
|
|
320
|
+
status: aiProfile.status,
|
|
321
|
+
setup: aiProfile.preview ? aiProfile.preview.matrix.setupCode : null,
|
|
322
|
+
usage: aiProfile.preview ? aiProfile.preview.matrix.usageLevel : null,
|
|
323
|
+
cell: aiProfile.preview ? aiProfile.preview.matrix.cell : null,
|
|
324
|
+
isAiNative: aiProfile.preview ? aiProfile.preview.matrix.isAiNative : false,
|
|
325
|
+
vision: aiProfile.preview ? aiProfile.preview.vision : null,
|
|
326
|
+
howIWork: aiProfile.preview ? aiProfile.preview.howIWork : null,
|
|
327
|
+
traction: aiProfile.preview ? aiProfile.preview.traction : null,
|
|
328
|
+
},
|
|
329
|
+
tierLegend: tierLadder(),
|
|
330
|
+
agents: displayAgents,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
151
335
|
function declined(lang) {
|
|
152
336
|
recordConsent('denied');
|
|
153
337
|
if (signupPhase() === 'account') {
|
|
@@ -157,27 +341,47 @@ function makeAiUsageTool(deps = {}) {
|
|
|
157
341
|
return { ok: false, reason: 'consent-declined', relayVerbatim: signupCopy(lang).aiUsageSkipped, message: 'Nothing was scanned. Show relayVerbatim once, word for word.' };
|
|
158
342
|
}
|
|
159
343
|
|
|
344
|
+
// The yes of a dialog in the sign-up: the disclaimers come in a second dialog, so they are read before anything is scanned.
|
|
345
|
+
async function confirmInDialog(ctx, c, texts) {
|
|
346
|
+
const again = await askChoice(ctx, { texts, question: c.aiUsageStart, options: [c.aiUsageYes, c.aiUsageSkip] });
|
|
347
|
+
return again.answer || null;
|
|
348
|
+
}
|
|
349
|
+
|
|
160
350
|
async function askConsent(lang, ctx) {
|
|
161
351
|
const c = signupCopy(lang);
|
|
162
|
-
const
|
|
352
|
+
const disclaimers = Object.values(usageDisclaimers(lang));
|
|
353
|
+
// In the sign-up the question goes first and short; its disclaimers are read only after a yes, before any scan.
|
|
354
|
+
const signup = signupPhase() === 'account';
|
|
355
|
+
const question = signup ? c.aiUsageAsk : c.aiUsageQuestion;
|
|
356
|
+
const texts = signup ? [] : disclaimers;
|
|
357
|
+
const options = [c.aiUsageYes, c.aiUsageSkip];
|
|
163
358
|
const asked = ctx && ctx.elicitation
|
|
164
|
-
? await askChoice(ctx, { texts, question
|
|
165
|
-
: { chat: chatChoice(
|
|
166
|
-
|
|
359
|
+
? await askChoice(ctx, { texts, question, options })
|
|
360
|
+
: { chat: chatChoice(question, options, texts) };
|
|
361
|
+
const answer = signup && asked.answer === c.aiUsageYes ? await confirmInDialog(ctx, c, disclaimers) : asked.answer;
|
|
362
|
+
if (answer === c.aiUsageSkip) {
|
|
167
363
|
dialogConsent = 'denied';
|
|
168
364
|
return declined(lang);
|
|
169
365
|
}
|
|
170
|
-
if (
|
|
366
|
+
if (answer === c.aiUsageYes) {
|
|
171
367
|
dialogConsent = 'granted';
|
|
172
368
|
return { ok: true, reason: 'consent-granted', consent: { granted: true }, message: 'The talent said yes in the dialog: do not ask again. Call ai_usage with consent.granted:true (and their email) to run it.' };
|
|
173
369
|
}
|
|
174
|
-
|
|
370
|
+
consentAsked = true;
|
|
371
|
+
const chat = asked.chat || chatChoice(question, options, texts);
|
|
372
|
+
const reply = {
|
|
175
373
|
ok: false,
|
|
176
374
|
reason: 'consent-required',
|
|
177
375
|
...(asked.dismissed ? { dismissed: asked.dismissed } : {}),
|
|
178
|
-
...
|
|
179
|
-
message: `Nothing was scanned. ${
|
|
376
|
+
...forChat(chat),
|
|
377
|
+
message: `Nothing was scanned. ${chat.choiceMessage} Then call ai_usage again with consent.granted:true only after they pick the first option, or consent.granted:false if they skip it.`,
|
|
180
378
|
};
|
|
379
|
+
if (!signup) return reply;
|
|
380
|
+
// A yes hands over the disclaimers, written before the scan starts; a skip hands over its fixed line, which the next question would carry.
|
|
381
|
+
return withNextSay(reply, [
|
|
382
|
+
handOver(CONSENT_QUESTION, { step: 'ai_usage', ifAnswer: [c.aiUsageYes], texts: disclaimers }),
|
|
383
|
+
handOver(CONSENT_QUESTION, { step: 'interview', ifAnswer: [c.aiUsageSkip], texts: [c.aiUsageSkipped] }),
|
|
384
|
+
]);
|
|
181
385
|
}
|
|
182
386
|
|
|
183
387
|
async function handler(args = {}, ctx = {}) {
|
|
@@ -190,7 +394,8 @@ function makeAiUsageTool(deps = {}) {
|
|
|
190
394
|
if (phase === 'waiting') {
|
|
191
395
|
return { ok: false, reason: 'account-pending', message: 'Nothing was scanned. Ask about AI usage only once signup_status says the account exists.' };
|
|
192
396
|
}
|
|
193
|
-
const consent = args.consent || {};
|
|
397
|
+
const consent = phase === 'account' && !consentAsked && dialogConsent === null ? {} : args.consent || {};
|
|
398
|
+
if (typeof consent.granted === 'boolean') settle(CONSENT_QUESTION, consent.granted ? signupCopy(textLang).aiUsageYes : signupCopy(textLang).aiUsageSkip);
|
|
194
399
|
if (consent.granted === false) return declined(textLang);
|
|
195
400
|
if (consent.granted === true && dialogConsent === 'denied') {
|
|
196
401
|
return { ok: false, reason: 'consent-declined', message: 'The talent skipped the AI-usage step in the dialog: nothing is scanned. Carry on without it. If they later ask for the scan themselves, call ai_usage without consent to ask them again.' };
|
|
@@ -225,177 +430,117 @@ function makeAiUsageTool(deps = {}) {
|
|
|
225
430
|
const email = typeof consent.email === 'string' ? consent.email.trim() : '';
|
|
226
431
|
const argv = buildArgv({ root, repoScope, lang: args.lang });
|
|
227
432
|
|
|
228
|
-
// In the sign-up the scan never holds the conversation: it runs and uploads in the background.
|
|
229
433
|
if (phase === 'account') {
|
|
230
|
-
|
|
231
|
-
|
|
434
|
+
if (pending && pending.registration && pending.outcome && !pending.outcome.uploaded) pending = null;
|
|
435
|
+
startSignupScan(argv, root, textLang);
|
|
232
436
|
return {
|
|
233
437
|
ok: true,
|
|
234
438
|
background: true,
|
|
235
|
-
send: current.send || { ok: false, skipped: true, reason: 'uploading' },
|
|
236
439
|
scope: { mode, root },
|
|
237
|
-
message: 'The analysis runs
|
|
440
|
+
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
441
|
};
|
|
239
442
|
}
|
|
240
443
|
|
|
241
|
-
const
|
|
242
|
-
const emailVerifiedByLogin =
|
|
243
|
-
sessionStatus(verifySession) === 'active'
|
|
244
|
-
&& !!verifySession
|
|
245
|
-
&& isValidEmail(verifySession.email);
|
|
246
|
-
|
|
444
|
+
const session = activeSession();
|
|
247
445
|
if (!isValidEmail(email)) {
|
|
248
446
|
throw new Error('consent.granted is true but consent.email is missing or invalid — ask the talent for their email.');
|
|
249
447
|
}
|
|
250
|
-
if (
|
|
251
|
-
recordConsent('granted',
|
|
448
|
+
if (session) {
|
|
449
|
+
recordConsent('granted', session.email, { verified: true });
|
|
252
450
|
} else {
|
|
253
451
|
recordConsent('granted', email, { verified: false });
|
|
254
452
|
}
|
|
255
453
|
|
|
256
|
-
const
|
|
257
|
-
const submittedEmail = canSubmit ? verifySession.email : null;
|
|
258
|
-
|
|
259
|
-
const { report, maturity } = await scanUsage(argv);
|
|
260
|
-
|
|
454
|
+
const scan = await scanUsage(argv);
|
|
261
455
|
let savedLocally = false;
|
|
262
456
|
try {
|
|
263
|
-
persistFootprint({ root, report, maturity });
|
|
457
|
+
persistFootprint({ root, report: scan.report, maturity: scan.maturity });
|
|
264
458
|
savedLocally = true;
|
|
265
459
|
} catch {
|
|
266
460
|
savedLocally = false;
|
|
267
461
|
}
|
|
268
462
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
463
|
+
if (!session) {
|
|
464
|
+
const text = renderFullReportText(scan.report, scan.maturity, { status: 'unavailable', preview: null }, { lang });
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
uploaded: false,
|
|
468
|
+
send: { ok: false, skipped: true, reason: 'email-unverified' },
|
|
469
|
+
savedLocally,
|
|
470
|
+
text,
|
|
471
|
+
scope: { mode, root },
|
|
472
|
+
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.",
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const review = openReview({ scan, root, lang: textLang, registration: false });
|
|
477
|
+
const reviewed = await presentReview(review, ctx);
|
|
478
|
+
return { ...reviewed, savedLocally, scope: { mode, root }, consent: { granted: true, email: session.email } };
|
|
479
|
+
}
|
|
273
480
|
|
|
274
|
-
|
|
481
|
+
async function waitForSignupScan(waitMs) {
|
|
482
|
+
if (job.state !== 'scanning') return;
|
|
483
|
+
await Promise.race([job.scan.catch(() => null), new Promise((resolve) => { setTimeout(resolve, waitMs).unref(); })]);
|
|
484
|
+
}
|
|
275
485
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
}
|
|
486
|
+
async function uploadHandler(args = {}, ctx = {}) {
|
|
487
|
+
if (!pending && job) {
|
|
488
|
+
const waitMs = Math.min(MAX_WAIT_MS, Math.max(0, Number(args.waitSeconds) || 0) * 1000);
|
|
489
|
+
await waitForSignupScan(waitMs);
|
|
490
|
+
if (job.state === 'scanning') {
|
|
491
|
+
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.' };
|
|
292
492
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// "My work with AI" preview: only after a real submit this run.
|
|
296
|
-
let aiProfile = { status: 'unavailable', preview: null };
|
|
297
|
-
if (submitted) {
|
|
298
|
-
const usageSession = loadAuthSession();
|
|
299
|
-
if (sessionStatus(usageSession) === 'active') {
|
|
300
|
-
aiProfile = await resolveAiProfilePreview(
|
|
301
|
-
{
|
|
302
|
-
session: usageSession,
|
|
303
|
-
endpoint: getAiProfileEndpoint(),
|
|
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
|
-
);
|
|
493
|
+
if (job.state === 'failed') {
|
|
494
|
+
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
495
|
}
|
|
496
|
+
openReview({ scan: await job.scan, root: job.root, lang: job.lang, registration: true });
|
|
314
497
|
}
|
|
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.`;
|
|
498
|
+
if (!pending) {
|
|
499
|
+
return { ok: false, reason: 'nothing-to-review', message: 'There is no analysis waiting for approval. Call ai_usage first.' };
|
|
342
500
|
}
|
|
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
|
-
};
|
|
501
|
+
if (pending.outcome) return pending.outcome.uploaded ? { ...pending.outcome, message: 'Already uploaded; it is not sent twice.' } : pending.outcome;
|
|
502
|
+
if (pending.askedInChat && args.confirm === false) return declineUpload(pending);
|
|
503
|
+
if (pending.askedInChat && args.confirm === true) {
|
|
504
|
+
const exclude = exclusionsFromChat(pending, args.exclude);
|
|
505
|
+
if (exclude.unknown.length > 0) {
|
|
506
|
+
const facts = reviewFacts(pending);
|
|
507
|
+
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.` };
|
|
508
|
+
}
|
|
509
|
+
return upload(pending, exclude);
|
|
510
|
+
}
|
|
511
|
+
return presentReview(pending, ctx);
|
|
384
512
|
}
|
|
385
513
|
|
|
386
|
-
|
|
514
|
+
const usage = {
|
|
387
515
|
name: 'ai_usage',
|
|
388
516
|
title: 'Analyse how you work with AI',
|
|
389
|
-
description:
|
|
517
|
+
description: "Asks you first and, only if you say yes, looks at which AI tools you use on this computer, your conversations with AI assistants and the history of your code projects. It shows you exactly what would go to your Shakers profile (fragments of those conversations, sample commands and your agents' definitions, with anything that looks like a secret removed) and uploads it only if you approve it; an AI model from an external provider (OpenAI, Google or Anthropic) analyses that text for Shakers to compute your evaluation and then discards it, keeping only the result (levels, scores, counts and the names of your tools and agents).",
|
|
390
518
|
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)
|
|
519
|
+
"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). The scan runs on the talent's own machine; an approved upload sends derived signals plus secret-scrubbed text (conversation fragments, sample commands, agent definitions), which an AI model from an external provider (OpenAI, Google or Anthropic) analyses for Shakers and discards, keeping only the result. 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
520
|
inputSchema: AI_USAGE_INPUT_SCHEMA,
|
|
393
521
|
handler,
|
|
394
522
|
};
|
|
523
|
+
|
|
524
|
+
const uploadTool = {
|
|
525
|
+
name: 'ai_usage_upload',
|
|
526
|
+
title: 'Review and upload your AI analysis',
|
|
527
|
+
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.',
|
|
528
|
+
guidance:
|
|
529
|
+
"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 a question to ask as its message says, 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.",
|
|
530
|
+
inputSchema: UPLOAD_INPUT_SCHEMA,
|
|
531
|
+
handler: uploadHandler,
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
return { usage, upload: uploadTool };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function makeAiUsageTool(deps = {}) {
|
|
538
|
+
return makeAiUsageTools(deps).usage;
|
|
395
539
|
}
|
|
396
540
|
|
|
397
541
|
module.exports = {
|
|
398
542
|
makeAiUsageTool,
|
|
543
|
+
makeAiUsageTools,
|
|
399
544
|
AI_USAGE_INPUT_SCHEMA,
|
|
400
545
|
buildArgv,
|
|
401
546
|
};
|