shakers-cli-beta 0.8.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/LICENSE +59 -0
  2. package/README.md +1175 -0
  3. package/bin/add-agent.js +88 -0
  4. package/bin/add-project.js +88 -0
  5. package/bin/add-role.js +61 -0
  6. package/bin/add-skill.js +88 -0
  7. package/bin/ai-usage.js +81 -0
  8. package/bin/alma.js +99 -0
  9. package/bin/applications.js +69 -0
  10. package/bin/availability.js +70 -0
  11. package/bin/certifications.js +69 -0
  12. package/bin/certify.js +73 -0
  13. package/bin/change-role.js +61 -0
  14. package/bin/config.js +120 -0
  15. package/bin/experiences.js +69 -0
  16. package/bin/find-projects.js +89 -0
  17. package/bin/invitations.js +69 -0
  18. package/bin/lang.js +70 -0
  19. package/bin/login.js +283 -0
  20. package/bin/mcp.js +98 -0
  21. package/bin/me.js +69 -0
  22. package/bin/onboarding.js +134 -0
  23. package/bin/portfolios.js +69 -0
  24. package/bin/rate.js +70 -0
  25. package/bin/register.js +325 -0
  26. package/bin/report-html.js +134 -0
  27. package/bin/save.js +77 -0
  28. package/bin/shakers.js +344 -0
  29. package/bin/share.js +72 -0
  30. package/bin/show-project.js +72 -0
  31. package/bin/socials.js +70 -0
  32. package/bin/superadmin.js +351 -0
  33. package/package.json +40 -0
  34. package/src/agent-category.js +13 -0
  35. package/src/agent-evaluation.js +177 -0
  36. package/src/agent-org-chart.js +355 -0
  37. package/src/agent-synthesis.js +132 -0
  38. package/src/agent-usage.js +118 -0
  39. package/src/agents-client.js +165 -0
  40. package/src/ai-fluency-cell.js +72 -0
  41. package/src/ai-profile-client.js +45 -0
  42. package/src/ai-profile-preview.js +154 -0
  43. package/src/ai.js +66 -0
  44. package/src/alma-client.js +123 -0
  45. package/src/alma-flow.js +32 -0
  46. package/src/alma-repo-context.js +47 -0
  47. package/src/alma-shell.js +161 -0
  48. package/src/ansi.js +62 -0
  49. package/src/applications-client.js +79 -0
  50. package/src/applications-flow.js +58 -0
  51. package/src/auth-client.js +144 -0
  52. package/src/auth-device-client.js +118 -0
  53. package/src/auth-method-picker.js +44 -0
  54. package/src/auth-methods.js +35 -0
  55. package/src/auth-session-store.js +214 -0
  56. package/src/authorship.js +203 -0
  57. package/src/automations-detector.js +170 -0
  58. package/src/availability-client.js +92 -0
  59. package/src/availability-flow.js +105 -0
  60. package/src/backend-request.js +170 -0
  61. package/src/blind-approval-extract.js +48 -0
  62. package/src/brand-ansi.js +14 -0
  63. package/src/brand-svg.js +9 -0
  64. package/src/browser-tools-detector.js +21 -0
  65. package/src/build-next-level.js +56 -0
  66. package/src/certifications-client.js +89 -0
  67. package/src/certifications-flow.js +75 -0
  68. package/src/certify-dimension-client.js +217 -0
  69. package/src/certify-dimension-flow.js +193 -0
  70. package/src/certify-disclaimer.js +51 -0
  71. package/src/certify-remediation-prompt.js +23 -0
  72. package/src/cli-args.js +83 -0
  73. package/src/config-dir.js +120 -0
  74. package/src/config.js +778 -0
  75. package/src/consent-flow.js +115 -0
  76. package/src/consent-skip.js +27 -0
  77. package/src/decisions-extract.js +43 -0
  78. package/src/detect-repos.js +145 -0
  79. package/src/detected-tools.js +15 -0
  80. package/src/detection-evidence.js +105 -0
  81. package/src/detection-noise.js +132 -0
  82. package/src/detectors.js +221 -0
  83. package/src/device-auth-store.js +100 -0
  84. package/src/device-login.js +118 -0
  85. package/src/email-verification.js +152 -0
  86. package/src/env-paths.js +16 -0
  87. package/src/find-projects-cache.js +89 -0
  88. package/src/find-projects-client.js +138 -0
  89. package/src/find-projects-flow.js +164 -0
  90. package/src/git-activity.js +123 -0
  91. package/src/graph-certs.js +58 -0
  92. package/src/graph-generator.js +230 -0
  93. package/src/graph-scan.js +239 -0
  94. package/src/i18n.js +3264 -0
  95. package/src/inter-fonts.js +6 -0
  96. package/src/interactive-select.js +183 -0
  97. package/src/interview-client.js +226 -0
  98. package/src/interview-transcript.js +56 -0
  99. package/src/inventory-client.js +90 -0
  100. package/src/invitations-client.js +66 -0
  101. package/src/invitations-flow.js +50 -0
  102. package/src/lang-client.js +147 -0
  103. package/src/lang-codes.js +14 -0
  104. package/src/lang-flow.js +85 -0
  105. package/src/legal-notice.js +53 -0
  106. package/src/locale.js +51 -0
  107. package/src/markdown-ansi.js +50 -0
  108. package/src/maturity.js +111 -0
  109. package/src/mcp-ai-usage-result-tool.js +109 -0
  110. package/src/mcp-ai-usage-tool.js +333 -0
  111. package/src/mcp-alma-tools.js +91 -0
  112. package/src/mcp-applications-tools.js +42 -0
  113. package/src/mcp-ask-tools.js +50 -0
  114. package/src/mcp-auth-form.js +282 -0
  115. package/src/mcp-availability-tools.js +43 -0
  116. package/src/mcp-certifications-tools.js +42 -0
  117. package/src/mcp-certify-dimension-tools.js +58 -0
  118. package/src/mcp-detector.js +223 -0
  119. package/src/mcp-device-login-tools.js +173 -0
  120. package/src/mcp-find-projects-tools.js +67 -0
  121. package/src/mcp-install.js +245 -0
  122. package/src/mcp-invitations-tools.js +42 -0
  123. package/src/mcp-lang-tools.js +39 -0
  124. package/src/mcp-onboarding-tools.js +388 -0
  125. package/src/mcp-portfolios-tools.js +46 -0
  126. package/src/mcp-profile-tools.js +46 -0
  127. package/src/mcp-rate-tools.js +42 -0
  128. package/src/mcp-register-tools.js +517 -0
  129. package/src/mcp-report-tools.js +101 -0
  130. package/src/mcp-role-tools.js +112 -0
  131. package/src/mcp-saved-positions-tools.js +55 -0
  132. package/src/mcp-server.js +170 -0
  133. package/src/mcp-show-project-tools.js +50 -0
  134. package/src/mcp-socials-tools.js +39 -0
  135. package/src/memory-structure-detector.js +108 -0
  136. package/src/model-call-record.js +198 -0
  137. package/src/onboarding-availability.js +26 -0
  138. package/src/onboarding-client.js +364 -0
  139. package/src/onboarding-flow.js +1078 -0
  140. package/src/onboarding-livekit-client.js +102 -0
  141. package/src/onboarding-status-client.js +42 -0
  142. package/src/open-file.js +32 -0
  143. package/src/osc-link.js +16 -0
  144. package/src/portfolio-client.js +164 -0
  145. package/src/portfolio-git-info.js +61 -0
  146. package/src/portfolios-client.js +85 -0
  147. package/src/portfolios-flow.js +79 -0
  148. package/src/profile-client.js +125 -0
  149. package/src/profile-flow.js +115 -0
  150. package/src/profile-switch.js +46 -0
  151. package/src/prompt-select.js +29 -0
  152. package/src/rate-client.js +82 -0
  153. package/src/rate-flow.js +137 -0
  154. package/src/render-ai-fluency.js +266 -0
  155. package/src/render-certification.js +177 -0
  156. package/src/render-html.js +140 -0
  157. package/src/render-report-text.js +27 -0
  158. package/src/render-sheet.js +261 -0
  159. package/src/render-terminal.js +109 -0
  160. package/src/render-usage-report-text.js +155 -0
  161. package/src/repo-context.js +215 -0
  162. package/src/report-gating.js +49 -0
  163. package/src/report-store.js +226 -0
  164. package/src/report-theme.js +177 -0
  165. package/src/report-view-client.js +50 -0
  166. package/src/reveal-output.js +29 -0
  167. package/src/roadmap-content.js +453 -0
  168. package/src/roadmap-personalization.js +132 -0
  169. package/src/roadmap-prompt.js +154 -0
  170. package/src/roles-client.js +114 -0
  171. package/src/roles-flow.js +192 -0
  172. package/src/sanitize-network-text.js +36 -0
  173. package/src/saved-positions-client.js +71 -0
  174. package/src/saved-positions-flow.js +50 -0
  175. package/src/scan-exclusions.js +50 -0
  176. package/src/scanner.js +612 -0
  177. package/src/scope-signals.js +148 -0
  178. package/src/secret-prompt.js +72 -0
  179. package/src/session-gate.js +30 -0
  180. package/src/session-refresh.js +51 -0
  181. package/src/session-scan.js +336 -0
  182. package/src/session-signals.js +36 -0
  183. package/src/shakers-wordmark.js +17 -0
  184. package/src/share-card.js +362 -0
  185. package/src/share-certification.js +111 -0
  186. package/src/share-consent-state.js +157 -0
  187. package/src/share-payload.js +300 -0
  188. package/src/share.js +184 -0
  189. package/src/show-project-client.js +58 -0
  190. package/src/show-project-flow.js +119 -0
  191. package/src/signup-client.js +91 -0
  192. package/src/skills-client.js +102 -0
  193. package/src/socials-client.js +91 -0
  194. package/src/socials-flow.js +83 -0
  195. package/src/specificity-extract.js +41 -0
  196. package/src/start-add-agents.js +306 -0
  197. package/src/start-add-portfolio.js +388 -0
  198. package/src/start-add-skills.js +260 -0
  199. package/src/stdin-ask.js +129 -0
  200. package/src/steering-extract.js +43 -0
  201. package/src/strip-internal-comments.js +45 -0
  202. package/src/tech-detector.js +295 -0
  203. package/src/templates/report-sheet.html +572 -0
  204. package/src/terminal-agents.js +199 -0
  205. package/src/terminal-format.js +85 -0
  206. package/src/terminal-progress.js +106 -0
  207. package/src/terminal-sections.js +219 -0
  208. package/src/tier-analysis.js +126 -0
  209. package/src/tier-engine.js +114 -0
  210. package/src/tier-projection.js +117 -0
  211. package/src/transcript-turns.js +49 -0
  212. package/src/translations-client.js +84 -0
  213. package/src/typewriter.js +154 -0
  214. package/src/usage-model-calls.js +119 -0
  215. package/src/usage-oneshot-commands.js +180 -0
  216. package/src/usage-repo-scope.js +162 -0
  217. package/src/usage-run-steps.js +338 -0
  218. package/src/work-streams.js +74 -0
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ // Ordinal -> positionId cache for the LAST human `find-projects` listing, so the
4
+ // terminal user can reference a project by the `N)` number they saw (the
5
+ // positionId is hidden from the human render). Written only on a human list,
6
+ // never on --json. Atomic 0600, same dir/style as the session store.
7
+
8
+ const crypto = require('crypto');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { getConfigDir } = require('./config-dir');
12
+
13
+ const CACHE_FILE_NAME = 'find-projects-cache.json';
14
+ const CACHE_FILE_VERSION = 1;
15
+
16
+ function cachePath(env = process.env) {
17
+ // Containment guard: the file name is a constant with no separators, so this
18
+ // can never escape — but resolving + checking closes the path-traversal gate
19
+ // cheaply and makes the invariant explicit. Throws on any escape; callers that
20
+ // read swallow it (return null), callers that write propagate (best-effort).
21
+ const base = path.resolve(getConfigDir(env));
22
+ const target = path.resolve(base, CACHE_FILE_NAME);
23
+ const rel = path.relative(base, target);
24
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
25
+ throw new Error('find-projects cache path escapes the config dir');
26
+ }
27
+ return target;
28
+ }
29
+
30
+ // Persists the display-ordered positionIds (index i -> ordinal i+1).
31
+ function writePositionCache(ids, env = process.env, { fs: fsImpl = fs } = {}) {
32
+ const clean = (Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string' && id);
33
+ const payload = `${JSON.stringify({ version: CACHE_FILE_VERSION, listedAt: new Date().toISOString(), ids: clean }, null, 2)}\n`;
34
+ const target = cachePath(env);
35
+ const dir = path.dirname(target);
36
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
37
+ const tmp = path.join(dir, `.${CACHE_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
38
+ let fd = null;
39
+ try {
40
+ fd = fsImpl.openSync(tmp, 'wx', 0o600);
41
+ fsImpl.writeFileSync(fd, payload);
42
+ try { fsImpl.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
43
+ fsImpl.closeSync(fd);
44
+ fd = null;
45
+ fsImpl.renameSync(tmp, target);
46
+ } catch (e) {
47
+ if (fd !== null) { try { fsImpl.closeSync(fd); } catch { /* already closed */ } }
48
+ try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
49
+ throw e;
50
+ }
51
+ return target;
52
+ }
53
+
54
+ // Returns the stored id list, or null when there is no usable cache.
55
+ function readPositionCache(env = process.env, { fs: fsImpl = fs } = {}) {
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(fsImpl.readFileSync(cachePath(env), 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ if (!parsed || typeof parsed !== 'object' || parsed.version !== CACHE_FILE_VERSION || !Array.isArray(parsed.ids)) return null;
63
+ const ids = parsed.ids.filter((id) => typeof id === 'string' && id);
64
+ return { ids };
65
+ }
66
+
67
+ // Resolves a raw positional argument to a positionId. A PURE integer is an
68
+ // ordinal into the last listing's cache; anything else (UUID / legacy Mongo id)
69
+ // is passed through unchanged. Never resolves to the wrong project silently.
70
+ function resolvePositionRef(raw, env = process.env, deps = {}) {
71
+ const read = deps.readPositionCache || readPositionCache;
72
+ const arg = typeof raw === 'string' ? raw.trim() : '';
73
+ if (!arg) return { ok: false, reason: 'no-id' };
74
+ if (!/^\d+$/.test(arg)) return { ok: true, id: arg }; // UUID / Mongo id passthrough
75
+ const n = Number.parseInt(arg, 10);
76
+ const cache = read(env);
77
+ if (!cache || cache.ids.length === 0) return { ok: false, reason: 'no-cache' };
78
+ if (n < 1 || n > cache.ids.length) return { ok: false, reason: 'out-of-range', count: cache.ids.length };
79
+ return { ok: true, id: cache.ids[n - 1] };
80
+ }
81
+
82
+ module.exports = {
83
+ CACHE_FILE_NAME,
84
+ CACHE_FILE_VERSION,
85
+ cachePath,
86
+ writePositionCache,
87
+ readPositionCache,
88
+ resolvePositionRef,
89
+ };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ // Find Positions list client (hub `works/positions/find`, talent Bearer). Each
4
+ // item carries the parent Project's id/name — a "project" here is the Position's
5
+ // parent, not a separate list. Pagination (limit/offset) and filters map to the
6
+ // endpoint's `criteria`; the server orders by match desc and returns the total.
7
+
8
+ const { postJsonWithTimeout } = require('./backend-request');
9
+ const { reasonForError } = require('./onboarding-client');
10
+
11
+ const DEFAULT_TIMEOUT_MS = 20000;
12
+ const DEFAULT_LIMIT = 15;
13
+ // Only tabs the endpoint distinguishes server-side: All (default order) and
14
+ // Saved (savedByMe). "Recommended" was a client illusion — dropped under
15
+ // pagination, where it would only cover the fetched page and mislead.
16
+ const TABS = ['all', 'saved'];
17
+ const ATTENDANCE = { remote: 'REMOTE', hybrid: 'HYBRID', 'in-person': 'IN_PERSON' };
18
+
19
+ function parseJson(raw) {
20
+ try {
21
+ return raw ? JSON.parse(raw) : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function bearerHeaders(accessToken) {
28
+ const headers = {};
29
+ if (typeof accessToken === 'string' && accessToken) headers.Authorization = `Bearer ${accessToken}`;
30
+ return headers;
31
+ }
32
+
33
+ function pick(obj, keys) {
34
+ for (const k of keys) {
35
+ const v = obj && obj[k];
36
+ if (v !== undefined && v !== null && v !== '') return v;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function numOrNull(v) {
42
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
43
+ }
44
+
45
+ function strArray(v) {
46
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : [];
47
+ }
48
+
49
+ // Only fields the readview's criteria map supports server-side: savedByMe,
50
+ // attendance, country (confirmed in FindPositionsService + its prisma readview).
51
+ // languages and skills are output-only (no filter field) → not built here.
52
+ function buildCriteria(tab, { limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null } = {}) {
53
+ const criteria = { limit, offset };
54
+ const conditions = [];
55
+ if (tab === 'saved') conditions.push({ field: 'savedByMe', operator: 'EQUALS', value: true });
56
+ if (attendance) conditions.push({ field: 'attendance', operator: 'IN', value: [attendance] });
57
+ if (country) conditions.push({ field: 'country', operator: 'IN', value: [country] });
58
+ if (conditions.length) criteria.filter = { conjunction: 'AND', conditions };
59
+ return criteria;
60
+ }
61
+
62
+ function normalizeItem(raw) {
63
+ const it = raw && typeof raw === 'object' ? raw : {};
64
+ const company = it.company && typeof it.company === 'object' ? it.company : {};
65
+ const budget = it.budget && typeof it.budget === 'object' ? it.budget : null;
66
+ return {
67
+ positionId: pick(it, ['positionId', 'id']),
68
+ title: pick(it, ['title']),
69
+ projectId: pick(it, ['projectId']),
70
+ projectName: pick(it, ['projectName']),
71
+ description: pick(it, ['description']),
72
+ country: pick(it, ['country']),
73
+ requiredMonthlyHours: numOrNull(it.requiredMonthlyHours),
74
+ attendance: pick(it, ['attendance']),
75
+ match: numOrNull(it.match),
76
+ isSaved: it.isSaved === true,
77
+ company: {
78
+ name: pick(company, ['name']),
79
+ restricted: company.restricted === true,
80
+ },
81
+ budget: budget
82
+ ? {
83
+ from: numOrNull(budget.from),
84
+ to: numOrNull(budget.to),
85
+ unit: pick(budget, ['unit']),
86
+ display: pick(budget, ['display']),
87
+ }
88
+ : null,
89
+ skillNames: strArray(it.skillNames),
90
+ languages: strArray(it.languages),
91
+ };
92
+ }
93
+
94
+ async function requestFindProjects(
95
+ { hubAccessToken } = {},
96
+ { tab = 'all', endpoint, timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null } = {},
97
+ ) {
98
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
99
+ if (!hubAccessToken) return { ok: false, reason: 'no-hub-token' };
100
+
101
+ const criteria = buildCriteria(tab, { limit, offset, attendance, country });
102
+ const url = `${endpoint}${endpoint.includes('?') ? '&' : '?'}criteria=${encodeURIComponent(JSON.stringify(criteria))}`;
103
+ let res;
104
+ try {
105
+ res = await postJsonWithTimeout(url, null, timeoutMs, 'GET', null, bearerHeaders(hubAccessToken));
106
+ } catch (e) {
107
+ return { ok: false, reason: (e && e.kind) || 'network-error' };
108
+ }
109
+ if (res.status < 200 || res.status >= 300) return { ok: false, reason: reasonForError(res.status, res.raw) };
110
+
111
+ const env = parseJson(res.raw);
112
+ if (!env || typeof env !== 'object') return { ok: false, reason: 'bad-response' };
113
+ const data = env.status === 'OK' ? env.data : env;
114
+ const rows = Array.isArray(data) ? data : (Array.isArray(data && data.items) ? data.items : null);
115
+ if (!rows) return { ok: false, reason: 'bad-response' };
116
+ return { ok: true, items: rows.map(normalizeItem), meta: (env && typeof env.meta === 'object' ? env.meta : null) };
117
+ }
118
+
119
+ // CLI+MCP entry: resolve the endpoint from config, then fetch one page.
120
+ async function fetchFindProjects(deps = {}, { hubAccessToken, tab = 'all', timeoutMs = DEFAULT_TIMEOUT_MS, limit = DEFAULT_LIMIT, offset = 0, attendance = null, country = null } = {}) {
121
+ const {
122
+ getFindPositionsEndpoint = require('./config').getFindPositionsEndpoint,
123
+ requestFindProjects: request = requestFindProjects,
124
+ } = deps;
125
+ const endpoint = getFindPositionsEndpoint();
126
+ if (!endpoint) return { ok: false, reason: 'no-endpoint' };
127
+ return request({ hubAccessToken }, { tab, endpoint, timeoutMs, limit, offset, attendance, country });
128
+ }
129
+
130
+ module.exports = {
131
+ TABS,
132
+ ATTENDANCE,
133
+ DEFAULT_LIMIT,
134
+ buildCriteria,
135
+ normalizeItem,
136
+ requestFindProjects,
137
+ fetchFindProjects,
138
+ };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ // `find-projects` flow: list the Positions/Projects available to the talent,
4
+ // paginated and filtered server-side via the endpoint's `criteria`. Read-only.
5
+
6
+ const { TABS, ATTENDANCE, DEFAULT_LIMIT } = require('./find-projects-client');
7
+ const { styleLabelPrefix } = require('./ansi');
8
+
9
+ function makeFindProjectsDeps(overrides = {}) {
10
+ const client = require('./find-projects-client');
11
+ return {
12
+ fetchFindProjects: (opts) => client.fetchFindProjects({}, opts),
13
+ writePositionCache: (ids) => require('./find-projects-cache').writePositionCache(ids),
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ // Normalize CLI/agent inputs to the shapes the endpoint's criteria expects.
19
+ function resolveQuery(opts = {}) {
20
+ const tab = TABS.includes(opts.tab) ? opts.tab : 'all';
21
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? Math.min(opts.limit, 50) : DEFAULT_LIMIT;
22
+ const page = Number.isInteger(opts.page) && opts.page > 0 ? opts.page : 1;
23
+ const offset = (page - 1) * limit;
24
+ const attRaw = typeof opts.attendance === 'string' ? opts.attendance.trim().toLowerCase() : null;
25
+ const attendance = attRaw && ATTENDANCE[attRaw] ? ATTENDANCE[attRaw] : null;
26
+ const attendanceInvalid = !!(attRaw && !ATTENDANCE[attRaw]);
27
+ const countryRaw = typeof opts.country === 'string' ? opts.country.trim().toUpperCase() : null;
28
+ const country = countryRaw && /^[A-Z]{2}$/.test(countryRaw) ? countryRaw : null;
29
+ const countryInvalid = !!(countryRaw && !/^[A-Z]{2}$/.test(countryRaw));
30
+ return { tab, limit, page, offset, attendance, country, attendanceInvalid, countryInvalid, recommended: !!opts.recommended };
31
+ }
32
+
33
+ // `--recommended` is a CLIENT-SIDE filter over the fetched page (the hub has no
34
+ // server-side "recommended" criterion — `match` is output-only). Recommended =
35
+ // items that have a match. If none on this page, fall back to the available
36
+ // items so the user is never left with an empty screen.
37
+ function splitRecommended(items, recommended) {
38
+ if (!recommended) return { shown: items, mode: null };
39
+ const rec = items.filter((it) => it.match != null);
40
+ if (rec.length > 0) return { shown: rec, mode: 'recommended' };
41
+ return { shown: items, mode: 'fallback' };
42
+ }
43
+
44
+ function budgetLine(item, fp) {
45
+ if (item.budget && item.budget.display) return item.budget.display;
46
+ if (item.budget && (item.budget.from != null || item.budget.to != null)) {
47
+ return `${item.budget.from ?? '·'}–${item.budget.to ?? '·'}${item.budget.unit ? ` ${item.budget.unit}` : ''}`;
48
+ }
49
+ return fp.budgetUnknown;
50
+ }
51
+
52
+ function formatItem(item, index, fp) {
53
+ const headline = item.title || item.projectName || fp.untitled;
54
+ const company = item.company && item.company.name
55
+ ? item.company.name
56
+ : (item.company && item.company.restricted ? fp.companyRestricted : null);
57
+ // No type segment (positions-only, always "position" → redundant).
58
+ const metaBits = [
59
+ item.attendance || null,
60
+ item.country || null,
61
+ item.requiredMonthlyHours != null ? fp.hoursPerMonth(item.requiredMonthlyHours) : null,
62
+ budgetLine(item, fp),
63
+ item.match != null ? fp.matchScore(item.match) : null,
64
+ item.isSaved ? fp.savedBadge : null,
65
+ ].filter(Boolean);
66
+ // Human render never prints positionId/projectId (they stay in --json only).
67
+ const lines = [`${index}) ${headline}${company ? ` — ${company}` : ''}`];
68
+ if (item.projectName && item.projectName !== headline) lines.push(` ${styleLabelPrefix(fp.projectLabel(item.projectName))}`);
69
+ lines.push(` ${metaBits.join(' · ')}`);
70
+ return lines.join('\n ');
71
+ }
72
+
73
+ // "Showing X–Y of TOTAL" (+ a next-page hint while more pages remain).
74
+ function paginationFooter(fp, meta, q, shown) {
75
+ if (!meta || typeof meta.total !== 'number') return null;
76
+ if (meta.total === 0) return null;
77
+ const from = q.offset + 1;
78
+ const to = q.offset + shown;
79
+ let line = fp.showing(from, to, meta.total);
80
+ if (meta.totalPages && q.page < meta.totalPages) line += ` · ${fp.morePages(q.page + 1)}`;
81
+ return line;
82
+ }
83
+
84
+ // Honest footer while showing the client-side recommended SUBSET of a page:
85
+ // never "X of TOTAL" (TOTAL is all available, not recommended). Pagination still
86
+ // works to pull more pages (which may carry more recommended).
87
+ function recommendedFooter(fp, count, meta, q) {
88
+ let line = fp.recommendedOnPage(count);
89
+ if (meta && meta.totalPages && q.page < meta.totalPages) line += ` · ${fp.morePages(q.page + 1)}`;
90
+ return line;
91
+ }
92
+
93
+ async function runFindProjects({ io, rawOut, session, lang, catalog, opts = {}, deps = makeFindProjectsDeps() }) {
94
+ const fp = catalog.findProjects;
95
+ const hubAccessToken = session ? session.hubAccessToken : null;
96
+ const q = resolveQuery(opts);
97
+
98
+ if (!opts.json) {
99
+ if (q.attendanceInvalid) io.warn(fp.attendanceIgnored);
100
+ if (q.countryInvalid) io.warn(fp.countryIgnored);
101
+ }
102
+
103
+ // In --json the loading label must not print (it would corrupt the JSON on a
104
+ // non-TTY stdout); only decorate the human path with the progress spinner.
105
+ const fetchPage = () => deps.fetchFindProjects({
106
+ hubAccessToken, tab: q.tab, limit: q.limit, offset: q.offset, attendance: q.attendance, country: q.country,
107
+ });
108
+ const res = opts.json ? await fetchPage() : await io.withProgress(fp.loading, fetchPage);
109
+ if (!res.ok) {
110
+ io.error(fp.fetchFailed(res.reason));
111
+ return { ok: false, reason: res.reason };
112
+ }
113
+
114
+ const items = Array.isArray(res.items) ? res.items : [];
115
+ // Client-side recommended filter (+ fallback to available), applied to both
116
+ // the human render and --json so an agent sees the same view (json keeps match).
117
+ const { shown, mode } = splitRecommended(items, q.recommended);
118
+
119
+ if (opts.json) {
120
+ (rawOut || ((s) => process.stdout.write(s)))(`${JSON.stringify({
121
+ ok: true,
122
+ tab: q.tab,
123
+ page: q.page,
124
+ limit: q.limit,
125
+ filters: { attendance: q.attendance, country: q.country, recommended: q.recommended },
126
+ recommended: mode,
127
+ meta: res.meta || null,
128
+ items: shown,
129
+ }, null, 2)}\n`);
130
+ return { ok: true, items: shown, meta: res.meta || null, recommended: mode };
131
+ }
132
+
133
+ // Persist the ordinal -> positionId map of what is SHOWN so `show-project N`
134
+ // matches the render. Human path only; a write failure never breaks the listing.
135
+ try { deps.writePositionCache(shown.map((it) => it.positionId)); } catch { /* cache is best-effort */ }
136
+
137
+ io.section(q.tab === 'saved' ? fp.titleSaved : fp.title);
138
+ if (items.length === 0) {
139
+ io.notify(q.page > 1 ? fp.noneOnPage : fp.noneAvailable);
140
+ return { ok: true, items: shown, meta: res.meta || null, recommended: mode };
141
+ }
142
+ // No recommended on this page -> we show the available ones, with a clear notice.
143
+ if (mode === 'fallback') io.notify(fp.recommendedNoneFallback);
144
+ shown.forEach((item, i) => {
145
+ if (i > 0) io.notify('');
146
+ io.notify(formatItem(item, i + 1, fp));
147
+ });
148
+ const footer = mode === 'recommended'
149
+ ? recommendedFooter(fp, shown.length, res.meta, q)
150
+ : paginationFooter(fp, res.meta, q, shown.length);
151
+ if (footer) io.notify(footer);
152
+ io.success(fp.done);
153
+ return { ok: true, items: shown, meta: res.meta || null, recommended: mode };
154
+ }
155
+
156
+ module.exports = {
157
+ makeFindProjectsDeps,
158
+ runFindProjects,
159
+ resolveQuery,
160
+ splitRecommended,
161
+ formatItem,
162
+ paginationFooter,
163
+ recommendedFooter,
164
+ };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('child_process');
4
+ const path = require('path');
5
+
6
+ const GIT_TIMEOUT_MS = 15000;
7
+ const MAX_FILE_TYPES = 50;
8
+
9
+ function runGit(root, args) {
10
+ try {
11
+ return execFileSync('git', ['-C', root, ...args], {
12
+ encoding: 'utf8',
13
+ timeout: GIT_TIMEOUT_MS,
14
+ stdio: ['ignore', 'pipe', 'ignore'],
15
+ maxBuffer: 64 * 1024 * 1024,
16
+ });
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function isGitRepo(root) {
23
+ return runGit(root, ['rev-parse', '--is-inside-work-tree']) !== null;
24
+ }
25
+
26
+ function extensionOf(rawPath) {
27
+ let p = rawPath;
28
+ const arrow = p.indexOf('=>');
29
+ if (arrow !== -1) {
30
+ p = p
31
+ .slice(arrow + 2)
32
+ .replace(/[{}]/g, '')
33
+ .trim();
34
+ }
35
+ const base = path.basename(p.trim());
36
+ const ext = path.extname(base).replace(/^\./, '').toLowerCase();
37
+ return ext || 'none';
38
+ }
39
+
40
+ function bumpFileType(filesByType, ext) {
41
+ if (Object.prototype.hasOwnProperty.call(filesByType, ext)) {
42
+ filesByType[ext] += 1;
43
+ return;
44
+ }
45
+ if (Object.keys(filesByType).length >= MAX_FILE_TYPES) return;
46
+ filesByType[ext] = 1;
47
+ }
48
+
49
+ function round2(n) {
50
+ return Math.round(n * 100) / 100;
51
+ }
52
+
53
+ // Deterministic git-activity signals for the project where `shakers ai-usage` runs (hybrid evaluation, Slice 1).
54
+ function collectGitActivity(root, verifiedEmail) {
55
+ if (!isGitRepo(root)) return null;
56
+
57
+ const sep = String.fromCharCode(1);
58
+ const unit = String.fromCharCode(31);
59
+ const log = runGit(root, [
60
+ 'log',
61
+ '--no-merges',
62
+ '--numstat',
63
+ `--pretty=format:${sep}%ae${unit}%at`,
64
+ ]);
65
+ if (log === null) return null;
66
+
67
+ const target = String(verifiedEmail || '')
68
+ .trim()
69
+ .toLowerCase();
70
+
71
+ let commitCount = 0;
72
+ let authoredCommitCount = 0;
73
+ let linesAdded = 0;
74
+ let linesDeleted = 0;
75
+ let minTs = null;
76
+ let maxTs = null;
77
+ const filesByType = {};
78
+
79
+ for (const rawLine of log.split('\n')) {
80
+ if (rawLine.startsWith(sep)) {
81
+ commitCount += 1;
82
+ const [email, atRaw] = rawLine.slice(1).split(unit);
83
+ if (target && String(email || '').trim().toLowerCase() === target) {
84
+ authoredCommitCount += 1;
85
+ }
86
+ const ts = Number.parseInt(atRaw, 10);
87
+ if (Number.isFinite(ts)) {
88
+ if (minTs === null || ts < minTs) minTs = ts;
89
+ if (maxTs === null || ts > maxTs) maxTs = ts;
90
+ }
91
+ continue;
92
+ }
93
+ const line = rawLine.trim();
94
+ if (!line) continue;
95
+ const parts = line.split('\t');
96
+ if (parts.length < 3) continue;
97
+ const [addedRaw, deletedRaw] = parts;
98
+ const filePath = parts.slice(2).join('\t');
99
+ if (addedRaw !== '-') linesAdded += Number.parseInt(addedRaw, 10) || 0;
100
+ if (deletedRaw !== '-') linesDeleted += Number.parseInt(deletedRaw, 10) || 0;
101
+ bumpFileType(filesByType, extensionOf(filePath));
102
+ }
103
+
104
+ if (commitCount === 0) return null;
105
+
106
+ const spanDays =
107
+ minTs !== null && maxTs !== null
108
+ ? Math.max(0, Math.floor((maxTs - minTs) / 86400))
109
+ : 0;
110
+ const velocity = round2(commitCount / Math.max(spanDays, 1));
111
+
112
+ return {
113
+ commitCount,
114
+ authoredCommitCount,
115
+ linesAdded,
116
+ linesDeleted,
117
+ filesByType,
118
+ velocity,
119
+ spanDays,
120
+ };
121
+ }
122
+
123
+ module.exports = { collectGitActivity, extensionOf, isGitRepo };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // graph-certs.js — report-store → `map` cert-drawer payload adapter.
4
+
5
+ const { getCatalog } = require('./i18n');
6
+ const { scoreBand, skillLevelForScore } = require('./render-certification');
7
+ const { sanitizeRenderText } = require('./sanitize-network-text');
8
+
9
+ function levelInfoFromCombinedLevel(combinedLevel) {
10
+ const key = typeof combinedLevel === 'string' ? combinedLevel.trim().toLowerCase() : '';
11
+ if (key === 'expert') return { key: 'expert', band: 'high' };
12
+ if (key === 'senior') return { key: 'senior', band: 'mid' };
13
+ if (key === 'middle') return { key: 'middle', band: 'low' };
14
+ return null;
15
+ }
16
+
17
+ // buildCertsPayload(project, lang) -> cert drawer payload | null `project` is a report-store project entry (state.projects[absRoot]).
18
+ function buildCertsPayload(project, lang) {
19
+ const t = getCatalog(lang);
20
+
21
+ const certObj = (project && project.certifications) || {};
22
+ const skills = Object.values(certObj)
23
+ .map((e) => e && e.item)
24
+ .filter(Boolean)
25
+ .map((item) => {
26
+ const res = item.result || {};
27
+ // `skillName` and `technology` both come off the wire (the service's certifiable list / verdict), and `technology` was one of the two fields issue 055 was opened for.
28
+ const base = sanitizeRenderText(item.skillName) || (item.skillId != null ? String(item.skillId) : 'Skill');
29
+ const name = item.technology ? `${base} · ${sanitizeRenderText(item.technology)}` : base;
30
+ // ADR-016: named level (Middle/Senior/Expert) instead of the numeric grade.
31
+ const skillReport = (t.certify && t.certify.report) || {};
32
+ const skillLevelNames = skillReport.skillLevels || {};
33
+ const { key: levelKey, band } = levelInfoFromCombinedLevel(res.combinedLevel) || skillLevelForScore(res.score);
34
+ return {
35
+ name,
36
+ band, // retained for the badge colour (unchanged palette)
37
+ levelKey,
38
+ levelName: levelKey ? (skillLevelNames[levelKey] || levelKey) : (skillLevelNames.na || '—'),
39
+ // Model-written text off the wire; sanitised (issue 055 — left raw by 052).
40
+ rationale: typeof res.rationale === 'string' ? sanitizeRenderText(res.rationale) : '',
41
+ improvements: Array.isArray(res.improvements)
42
+ ? res.improvements.map((i) => (typeof i === 'string' ? sanitizeRenderText(i) : i))
43
+ : [],
44
+ };
45
+ });
46
+
47
+ if (!skills.length) return null;
48
+
49
+ const labels = {
50
+ skillsTitle: lang === 'es' ? 'Skills evaluadas' : 'Skills evaluated',
51
+ empty: lang === 'es' ? 'Aún no hay certificaciones para este proyecto.' : 'No certifications for this project yet.',
52
+ improvements: lang === 'es' ? 'Mejoras sugeridas' : 'Suggested improvements',
53
+ };
54
+
55
+ return { labels, skills };
56
+ }
57
+
58
+ module.exports = { buildCertsPayload, levelInfoFromCombinedLevel };