@pitcher/cli-scripts 3.2.0 → 3.3.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 (60) hide show
  1. package/dist/src/commands/dsrLanding.js +1 -1
  2. package/dist/src/commands/roleplay.d.ts +154 -3
  3. package/dist/src/commands/roleplay.js +276 -23
  4. package/dist/src/core/dsr-engagement.d.ts +152 -0
  5. package/dist/src/core/dsr-engagement.js +301 -0
  6. package/dist/src/core/envelope.d.ts +1 -0
  7. package/dist/src/core/envelope.js +6 -1
  8. package/dist/src/core/errors.d.ts +3 -1
  9. package/dist/src/core/errors.js +2 -2
  10. package/dist/src/core/http.js +7 -5
  11. package/dist/src/core/ld.js +2 -1
  12. package/dist/src/core/me.d.ts +3 -0
  13. package/dist/src/core/me.js +10 -0
  14. package/dist/src/core/roleplay-stats.d.ts +48 -7
  15. package/dist/src/core/roleplay-stats.js +68 -11
  16. package/dist/src/mcp/apps/confirm/define.d.ts +66 -0
  17. package/dist/src/mcp/apps/confirm/define.js +208 -0
  18. package/dist/src/mcp/apps/confirm/hash.d.ts +4 -0
  19. package/dist/src/mcp/apps/confirm/hash.js +23 -0
  20. package/dist/src/mcp/apps/confirm/types.d.ts +96 -0
  21. package/dist/src/mcp/apps/confirm/types.js +15 -0
  22. package/dist/src/mcp/apps/contentPicker.d.ts +104 -0
  23. package/dist/src/mcp/apps/contentPicker.js +268 -0
  24. package/dist/src/mcp/apps/dsrEngagement.d.ts +194 -0
  25. package/dist/src/mcp/apps/dsrEngagement.js +737 -0
  26. package/dist/src/mcp/apps/registry.d.ts +14 -0
  27. package/dist/src/mcp/apps/registry.js +25 -0
  28. package/dist/src/mcp/apps/roleplayManager.d.ts +228 -0
  29. package/dist/src/mcp/apps/roleplayManager.js +606 -0
  30. package/dist/src/mcp/apps/roleplayReps.d.ts +79 -0
  31. package/dist/src/mcp/apps/roleplayReps.js +328 -0
  32. package/dist/src/mcp/apps/roleplayWrites.d.ts +6 -0
  33. package/dist/src/mcp/apps/roleplayWrites.js +509 -0
  34. package/dist/src/mcp/apps/text.d.ts +41 -0
  35. package/dist/src/mcp/apps/text.js +58 -0
  36. package/dist/src/mcp/apps/types.d.ts +23 -0
  37. package/dist/src/mcp/apps/types.js +12 -0
  38. package/dist/src/mcp/apps/writeConfirm.d.ts +3 -0
  39. package/dist/src/mcp/apps/writeConfirm.js +8 -0
  40. package/dist/src/mcp/groups.d.ts +42 -0
  41. package/dist/src/mcp/groups.js +89 -0
  42. package/dist/src/mcp/hosted.d.ts +36 -0
  43. package/dist/src/mcp/hosted.js +47 -1
  44. package/dist/src/mcp/index.d.ts +9 -3
  45. package/dist/src/mcp/index.js +9 -2
  46. package/dist/src/mcp/prompts/registry.js +8 -7
  47. package/dist/src/mcp/prompts/types.d.ts +3 -0
  48. package/dist/src/mcp/registry.d.ts +4 -0
  49. package/dist/src/mcp/registry.js +29 -24
  50. package/dist/src/mcp/renderPolicy.d.ts +1 -0
  51. package/dist/src/mcp/renderPolicy.js +8 -2
  52. package/dist/src/mcp/resources/registry.js +2 -2
  53. package/dist/src/mcp/resources/types.d.ts +3 -0
  54. package/dist/src/mcp/result.d.ts +2 -1
  55. package/dist/src/mcp/result.js +8 -6
  56. package/dist/src/mcp/server.d.ts +62 -0
  57. package/dist/src/mcp/server.js +161 -10
  58. package/dist/src/mcp/tools/roleplay.js +34 -5
  59. package/dist/src/mcp/tools/types.d.ts +35 -0
  60. package/package.json +2 -1
@@ -79,7 +79,7 @@ export async function runDsrLandingCreate(opts, deps = {}) {
79
79
  catch (e) {
80
80
  const err = toCliError(e);
81
81
  return fail(createCliError(err.code, `template created (id ${id}), but set-default failed: ${err.message}. ` +
82
- `Retry with: p dsr-landing set-default --instance-id ${opts.instanceId} --id ${id}`));
82
+ `Retry with: p dsr-landing set-default --instance-id ${opts.instanceId} --id ${id}`, err.backendStatus));
83
83
  }
84
84
  }
85
85
  return ok(created);
@@ -2,6 +2,7 @@ import { Command } from 'commander';
2
2
  import type { AxiosInstance } from 'axios';
3
3
  import { type CommonOpts } from '../core/http.js';
4
4
  import { type Envelope } from '../core/envelope.js';
5
+ import { type AssignmentProgressLike, type RepStatus, type RoleplayAssignmentLike, type RoleplaySessionLike } from '../core/roleplay-stats.js';
5
6
  /**
6
7
  * AI Roleplay reads (PIT-9200, Phase 1: read-only). Two sources:
7
8
  * - clickhouse-api `/analytics/roleplay/{kpis,sessions}` (the `roleplay_statements` replica,
@@ -17,6 +18,7 @@ export interface RoleplayDeps {
17
18
  export declare const ROLEPLAY_DIFFICULTIES: readonly ["easy", "medium", "hard"];
18
19
  export declare const ROLEPLAY_SESSION_SORT: readonly ["session_at", "rep_name", "score", "duration_ms"];
19
20
  export declare const ROLEPLAY_GROUP_BY: readonly ["rep", "scenario"];
21
+ export declare const REP_STATUSES: readonly RepStatus[];
20
22
  /** rep-summary reads at most this many pages of this size (5000 sessions), newest first. */
21
23
  export declare const REP_SUMMARY_PAGE_SIZE = 1000;
22
24
  export declare const REP_SUMMARY_MAX_PAGES = 5;
@@ -25,6 +27,11 @@ export declare const REP_SUMMARY_MAX_GROUPS = 50;
25
27
  export declare const COACH_DATA_SESSION_CAP = 1000;
26
28
  export declare const TEAM_MAX_ASSIGNMENTS = 50;
27
29
  export declare const TEAM_MAX_REPS_PER_ASSIGNMENT = 100;
30
+ /**
31
+ * Pass progress (PIT-9971) is read in pages of 2000 (assignment, rep) pairs. Above this many pages the
32
+ * tools use no progress at all (the manager app allows 100,000 pages; one tool call stays bounded).
33
+ */
34
+ export declare const PROGRESS_MAX_PAGES = 10;
28
35
  export interface RoleplayFilterOpts extends CommonOpts {
29
36
  instanceId?: string;
30
37
  startDate?: string;
@@ -33,6 +40,7 @@ export interface RoleplayFilterOpts extends CommonOpts {
33
40
  scenario?: string;
34
41
  difficulty?: string;
35
42
  }
43
+ export declare const finiteOrNull: (v: unknown) => number | null;
36
44
  /** Totals for the window: sessions, scored/unscored, mean score over SCORED sessions only, active reps. */
37
45
  export declare function runRoleplayKpis(opts: RoleplayFilterOpts, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
38
46
  declare const SESSION_FIELDS: readonly ["entry_id", "session_at", "rep_name", "who", "scenario_name", "persona_name", "difficulty", "score", "evaluated", "rating", "duration_ms"];
@@ -55,15 +63,102 @@ export interface RoleplayRepSummaryOpts extends RoleplayFilterOpts {
55
63
  * window holds more than that; then the figures cover the newest 5000 only.
56
64
  */
57
65
  export declare function runRoleplayRepSummary(opts: RoleplayRepSummaryOpts, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
66
+ export interface CoachAssignment extends RoleplayAssignmentLike {
67
+ id: string;
68
+ scenarioName?: string;
69
+ active?: boolean;
70
+ contentType?: string;
71
+ resolvedUserNames?: Record<string, string>;
72
+ }
73
+ /** A bundle session row: coach/data spreads the appsDB data and adds the entry `id` (transcript stripped). */
74
+ export type CoachSession = RoleplaySessionLike & {
75
+ id: string;
76
+ sessionType?: string;
77
+ evaluationStatus?: string;
78
+ };
79
+ type ProgressEntry = {
80
+ assignmentId?: unknown;
81
+ repUserId?: unknown;
82
+ } & Partial<Record<keyof AssignmentProgressLike, unknown>>;
83
+ interface ProgressPage {
84
+ page: number;
85
+ pageSize: number;
86
+ totalPairs: number;
87
+ hasMore: boolean;
88
+ }
89
+ /** A `cch-manager-comment` row as coach/data returns it: the appsDB data plus the entry `id` (soft-deleted rows excluded). */
90
+ export type CoachComment = Record<string, unknown> & {
91
+ id: string;
92
+ managerId?: string | number;
93
+ managerName?: string;
94
+ repUserId?: string | number;
95
+ assignmentId?: string;
96
+ sessionEntryId?: string | null;
97
+ comment?: string;
98
+ };
99
+ export interface CoachBundle {
100
+ scope?: 'instance' | 'coach';
101
+ assignments?: CoachAssignment[];
102
+ sessions?: CoachSession[];
103
+ comments?: CoachComment[];
104
+ assignmentProgress?: ProgressEntry[];
105
+ assignmentProgressPage?: ProgressPage;
106
+ }
58
107
  export interface RoleplayTeamOpts extends CommonOpts {
59
108
  instanceId?: string;
60
109
  assignmentId?: string;
61
110
  status?: string;
62
111
  }
112
+ export type CoachScope = 'instance' | 'coach';
113
+ /** The caller's OWN coaching scope (coach/data without `scope`): what a coach may comment on (PIT-10113). */
114
+ export declare function getOwnCoachBundle(client: AxiosInstance, instanceId: string): Promise<CoachBundle>;
115
+ /** The manager app's progress map key (`useManagerData.ts`). */
116
+ export declare const progressKey: (assignmentId: string, repUserId: string) => string;
117
+ export interface Coverage {
118
+ sessionsCapped: boolean;
119
+ progress: 'full' | 'none';
120
+ /** (assignment, rep) pairs of the roleplay model with no progress entry when progress is full, else 0. */
121
+ pairsWithoutProgress: number;
122
+ }
123
+ export interface TeamModel {
124
+ /** The scope the server applied (an older next-core that ignores scope=instance answers 'coach'). */
125
+ scope: CoachScope;
126
+ /** The scope this load asked for first: 'coach' when the caller passed scopePref 'coach'. */
127
+ requested: CoachScope;
128
+ coverage: Coverage;
129
+ warnings: string[];
130
+ /** contentType !== 'practice', D18 order. */
131
+ assignments: CoachAssignment[];
132
+ /** sessionType !== 'manager-video-practice'. */
133
+ sessions: CoachSession[];
134
+ progress: ReadonlyMap<string, AssignmentProgressLike> | null;
135
+ }
136
+ /** D18 rep order: the reps who need the manager first. */
137
+ export declare const STATUS_RANK: Record<RepStatus, number>;
138
+ /** D18 assignment order: active first, then due date ascending (none last), then scenario name, then id. */
139
+ export declare function compareAssignments(a: CoachAssignment, b: CoachAssignment): number;
140
+ /** D18 rep order: status rank, then name (no name last), then user id. */
141
+ export declare function compareReps(a: {
142
+ status: RepStatus;
143
+ name: string | null;
144
+ user_id: string;
145
+ }, b: {
146
+ status: RepStatus;
147
+ name: string | null;
148
+ user_id: string;
149
+ }): number;
150
+ /**
151
+ * The one team derivation shared by `roleplay team` and the roleplay manager view (PIT-10068): the
152
+ * coach bundle, its pass progress, the roleplay filters and the D18 order, so the text tool and the
153
+ * view always cut the same 50 assignments.
154
+ */
155
+ export declare function loadTeamModel(client: AxiosInstance, instanceId: string, scopePref?: CoachScope): Promise<TeamModel>;
63
156
  /**
64
157
  * Assignment completion per rep, ported from the manager app: an admin gets every roleplay
65
158
  * assignment on the instance (`scope=instance`), anyone else the assignments they coach.
66
- * Statuses follow `deriveStatus`; raw sessions, evaluations and comments never leave.
159
+ * Statuses, session counts and best scores follow `deriveStatus` / `repStats` with the server's
160
+ * pass progress when it loads (PIT-9971); assignments and reps in D18 order (`loadTeamModel`).
161
+ * Raw sessions, evaluations and comments never leave.
67
162
  */
68
163
  export declare function runRoleplayTeam(opts: RoleplayTeamOpts, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
69
164
  export interface RoleplaySessionGetOpts extends CommonOpts {
@@ -71,8 +166,10 @@ export interface RoleplaySessionGetOpts extends CommonOpts {
71
166
  entryId?: string;
72
167
  }
73
168
  /**
74
- * The allowlist. Never the transcript, messages, contact name, graded snapshot, voice session,
75
- * recording or audio ids, or any video-evaluation field.
169
+ * The allowlist, shared by `roleplay session` and the manager view's scorecard. Never the transcript,
170
+ * messages, contact name, graded snapshot, voice session, recording or audio ids, or any
171
+ * video-evaluation field. The two scales (`grading_scale`, `max_score`) are numbers only (PIT-10068):
172
+ * legacy evaluations grade on 5, and a skill score is meaningless without its scale.
76
173
  */
77
174
  export declare function pickSessionDetail(id: string, data: Record<string, unknown>): Record<string, unknown>;
78
175
  export declare function runRoleplaySessionGet(opts: RoleplaySessionGetOpts, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
@@ -81,5 +178,59 @@ export declare function pickScenario(s: Record<string, unknown>): Record<string,
81
178
  export declare function runRoleplayScenariosList(opts: CommonOpts & {
82
179
  instanceId?: string;
83
180
  }, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
181
+ export declare const ROLEPLAY_PATH_STATUSES: readonly ["published", "draft", "all"];
182
+ export interface RoleplayPathRow {
183
+ id: string;
184
+ name: string;
185
+ status: string;
186
+ version: number;
187
+ stages: number;
188
+ steps: number;
189
+ default_passing_score: number | null;
190
+ updated_at: string | null;
191
+ }
192
+ /** The trimmed path: counts, never the stage/step bodies. */
193
+ export declare function pickPath(p: Record<string, unknown>): RoleplayPathRow;
194
+ /** next-core `GET roleplay/paths` (builder roles; 404 when paths are off or the caller is not a member). */
195
+ export declare function fetchPaths(client: AxiosInstance, instanceId: string, status?: (typeof ROLEPLAY_PATH_STATUSES)[number]): Promise<Record<string, unknown>[]>;
196
+ export declare function runRoleplayPathsList(opts: CommonOpts & {
197
+ instanceId?: string;
198
+ status?: string;
199
+ }, deps?: RoleplayDeps): Promise<Envelope<unknown>>;
200
+ /** next-core `GET roleplay/path-assignments`: the records in the caller's view (admins get the membership keys). */
201
+ export declare function fetchPathRecords(client: AxiosInstance, instanceId: string): Promise<Record<string, unknown>[]>;
202
+ export declare function postCoachComment(client: AxiosInstance, args: {
203
+ instanceId: string;
204
+ assignmentId: string;
205
+ repUserId: string;
206
+ sessionEntryId: string | null;
207
+ comment: string;
208
+ }, idempotencyKey: string): Promise<{
209
+ body: {
210
+ id?: string;
211
+ data?: Record<string, unknown>;
212
+ };
213
+ replayed: boolean;
214
+ }>;
215
+ export declare function deleteCoachComment(client: AxiosInstance, args: {
216
+ instanceId: string;
217
+ commentId: string;
218
+ }, idempotencyKey: string): Promise<{
219
+ replayed: boolean;
220
+ }>;
221
+ export declare function postPathAssignment(client: AxiosInstance, instanceId: string, body: {
222
+ pathId: string;
223
+ groupIds: string[];
224
+ recipientUserIds: string[];
225
+ managerIds: string[];
226
+ mandatory: boolean;
227
+ dueDate: string | null;
228
+ }, idempotencyKey: string): Promise<{
229
+ body: {
230
+ record?: Record<string, unknown>;
231
+ warnings?: unknown[];
232
+ };
233
+ replayed: boolean;
234
+ }>;
84
235
  export declare function createRoleplayCommand(): Command;
85
236
  export {};
@@ -4,12 +4,12 @@ import { ok, fail } from '../core/envelope.js';
4
4
  import { createCliError, isCliError } from '../core/errors.js';
5
5
  import { emitEnvelope } from '../core/output.js';
6
6
  import { pageSizeFor } from '../core/analytics-shape.js';
7
- import { assignmentSessions, repStats, sessionScore, skillAverages, } from '../core/roleplay-stats.js';
7
+ import { assignmentSessions, repStats, scaleOrNull, sessionScore, skillAverages, } from '../core/roleplay-stats.js';
8
8
  import { withAuthFlags } from './withAuth.js';
9
9
  export const ROLEPLAY_DIFFICULTIES = ['easy', 'medium', 'hard'];
10
10
  export const ROLEPLAY_SESSION_SORT = ['session_at', 'rep_name', 'score', 'duration_ms'];
11
11
  export const ROLEPLAY_GROUP_BY = ['rep', 'scenario'];
12
- const REP_STATUSES = ['passed', 'in_progress', 'not_started', 'overdue', 'needs_coaching'];
12
+ export const REP_STATUSES = ['passed', 'in_progress', 'not_started', 'overdue', 'needs_coaching'];
13
13
  /** rep-summary reads at most this many pages of this size (5000 sessions), newest first. */
14
14
  export const REP_SUMMARY_PAGE_SIZE = 1000;
15
15
  export const REP_SUMMARY_MAX_PAGES = 5;
@@ -18,6 +18,11 @@ export const REP_SUMMARY_MAX_GROUPS = 50;
18
18
  export const COACH_DATA_SESSION_CAP = 1000;
19
19
  export const TEAM_MAX_ASSIGNMENTS = 50;
20
20
  export const TEAM_MAX_REPS_PER_ASSIGNMENT = 100;
21
+ /**
22
+ * Pass progress (PIT-9971) is read in pages of 2000 (assignment, rep) pairs. Above this many pages the
23
+ * tools use no progress at all (the manager app allows 100,000 pages; one tool call stays bounded).
24
+ */
25
+ export const PROGRESS_MAX_PAGES = 10;
21
26
  const COACH_BASE = '/core/api/protected/roleplay/coach';
22
27
  const APPSDB_QUERY = '/core/api/protected/appsdb/query';
23
28
  function requireInstance(opts, verb) {
@@ -47,7 +52,7 @@ function filterParams(opts, instanceId) {
47
52
  function windowOf(opts) {
48
53
  return { start_date: opts.startDate ?? null, end_date: opts.endDate ?? null };
49
54
  }
50
- const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
55
+ export const finiteOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
51
56
  /** clickhouse-api's roleplay routes are instance/org-admin only: say so, and where a manager goes instead. */
52
57
  function adminOnlyError(e) {
53
58
  const err = toCliError(e);
@@ -209,8 +214,15 @@ export async function runRoleplayRepSummary(opts, deps = {}) {
209
214
  return fail(adminOnlyError(e));
210
215
  }
211
216
  }
212
- async function getCoachBundle(client, instanceId) {
217
+ /** `scopePref: 'coach'` skips the instance attempt; otherwise instance first, the caller's coaching scope on 403. */
218
+ async function getCoachBundle(client, instanceId, scopePref) {
213
219
  const headers = { 'x-instance-id': instanceId };
220
+ const asCoach = async () => {
221
+ const res = await client.get(`${COACH_BASE}/data`, { params: { instance_id: instanceId }, headers });
222
+ return { bundle: (res.data ?? {}), requested: 'coach' };
223
+ };
224
+ if (scopePref === 'coach')
225
+ return asCoach();
214
226
  try {
215
227
  const res = await client.get(`${COACH_BASE}/data`, { params: { instance_id: instanceId, scope: 'instance' }, headers });
216
228
  return { bundle: (res.data ?? {}), requested: 'instance' };
@@ -219,14 +231,156 @@ async function getCoachBundle(client, instanceId) {
219
231
  // Not an instance or org admin: fall back to the caller's own coaching assignments.
220
232
  if (toCliError(e).code !== 'AUTH')
221
233
  throw e;
222
- const res = await client.get(`${COACH_BASE}/data`, { params: { instance_id: instanceId }, headers });
223
- return { bundle: (res.data ?? {}), requested: 'coach' };
234
+ return asCoach();
235
+ }
236
+ }
237
+ /** The caller's OWN coaching scope (coach/data without `scope`): what a coach may comment on (PIT-10113). */
238
+ export async function getOwnCoachBundle(client, instanceId) {
239
+ return (await getCoachBundle(client, instanceId, 'coach')).bundle;
240
+ }
241
+ /** The manager app's progress map key (`useManagerData.ts`). */
242
+ export const progressKey = (assignmentId, repUserId) => `${assignmentId}\u0000${repUserId}`;
243
+ function toProgress(e) {
244
+ return {
245
+ passed: e.passed === true,
246
+ passSource: e.passSource === 'stored' || e.passSource === 'live' ? e.passSource : null,
247
+ attempts: typeof e.attempts === 'number' && Number.isFinite(e.attempts) ? e.attempts : 0,
248
+ bestScore: finiteOrNull(e.bestScore),
249
+ latestAt: typeof e.latestAt === 'string' ? e.latestAt : null,
250
+ };
251
+ }
252
+ const isProgressPage = (b) => Array.isArray(b.assignmentProgress) && !!b.assignmentProgressPage && typeof b.assignmentProgressPage.totalPairs === 'number';
253
+ const PROGRESS_CHANGED = Symbol('progress-changed');
254
+ /** Every page from 0, progress-only (`fetchAllProgressPages`, manager-app `lib/api.ts`). */
255
+ async function fetchAllProgressPages(client, instanceId, scope) {
256
+ const out = [];
257
+ let total = null;
258
+ for (let page = 0; page < PROGRESS_MAX_PAGES; page += 1) {
259
+ const params = { instance_id: instanceId, assignment_page: page };
260
+ if (scope === 'instance')
261
+ params.scope = 'instance';
262
+ const res = await client.get(`${COACH_BASE}/data`, { params, headers: { 'x-instance-id': instanceId } });
263
+ const body = (res.data ?? {});
264
+ if (!isProgressPage(body))
265
+ throw new Error(`coach/data assignment_page=${page}: no progress in the answer`);
266
+ if (total !== null && body.assignmentProgressPage.totalPairs !== total)
267
+ return PROGRESS_CHANGED;
268
+ total = body.assignmentProgressPage.totalPairs;
269
+ out.push(...body.assignmentProgress);
270
+ if (!body.assignmentProgressPage.hasMore)
271
+ return out;
272
+ }
273
+ throw new Error(`coach/data: assignment progress has more than ${PROGRESS_MAX_PAGES} pages`);
274
+ }
275
+ const PROGRESS_LOST = 'Pass progress could not be loaded completely, so statuses, session counts and best scores come from the newest sessions only';
276
+ /**
277
+ * `loadAssignmentProgress`: one page = the bundle's page 0 as is; more = every page from 0, progress-only
278
+ * with the APPLIED scope, restarted once if `totalPairs` changes; any failure, a second change or more
279
+ * than PROGRESS_MAX_PAGES pages = no progress (all-or-nothing). Absent progress is normal (empty bundle,
280
+ * older next-core, a server-side progress failure) and is not a warning.
281
+ */
282
+ async function loadProgress(client, instanceId, bundle, scope, warnings) {
283
+ if (!isProgressPage(bundle))
284
+ return null;
285
+ const first = bundle.assignmentProgressPage;
286
+ let entries = null;
287
+ if (!first.hasMore)
288
+ entries = bundle.assignmentProgress;
289
+ else if (first.pageSize > 0 && Math.ceil(first.totalPairs / first.pageSize) > PROGRESS_MAX_PAGES)
290
+ entries = null;
291
+ else {
292
+ try {
293
+ const pass = await fetchAllProgressPages(client, instanceId, scope);
294
+ const again = pass === PROGRESS_CHANGED ? await fetchAllProgressPages(client, instanceId, scope) : pass;
295
+ entries = again === PROGRESS_CHANGED ? null : again;
296
+ }
297
+ catch {
298
+ entries = null;
299
+ }
300
+ }
301
+ if (!entries) {
302
+ warnings.push(PROGRESS_LOST);
303
+ return null;
304
+ }
305
+ const map = new Map();
306
+ for (const e of entries) {
307
+ if (typeof e.assignmentId !== 'string' || (typeof e.repUserId !== 'string' && typeof e.repUserId !== 'number'))
308
+ continue;
309
+ map.set(progressKey(e.assignmentId, String(e.repUserId)), toProgress(e));
310
+ }
311
+ return map;
312
+ }
313
+ /** D18 rep order: the reps who need the manager first. */
314
+ export const STATUS_RANK = { overdue: 0, needs_coaching: 1, in_progress: 2, not_started: 3, passed: 4 };
315
+ const cmpStr = (a, b) => (a ?? '').localeCompare(b ?? '', 'en');
316
+ /** D18 assignment order: active first, then due date ascending (none last), then scenario name, then id. */
317
+ export function compareAssignments(a, b) {
318
+ const active = Number(b.active !== false) - Number(a.active !== false);
319
+ if (active)
320
+ return active;
321
+ if (a.dueDate !== b.dueDate) {
322
+ if (!a.dueDate)
323
+ return 1;
324
+ if (!b.dueDate)
325
+ return -1;
326
+ return a.dueDate < b.dueDate ? -1 : 1;
224
327
  }
328
+ return cmpStr(a.scenarioName, b.scenarioName) || cmpStr(a.id, b.id);
329
+ }
330
+ /** D18 rep order: status rank, then name (no name last), then user id. */
331
+ export function compareReps(a, b) {
332
+ const rank = STATUS_RANK[a.status] - STATUS_RANK[b.status];
333
+ if (rank)
334
+ return rank;
335
+ if (a.name !== b.name) {
336
+ if (a.name === null)
337
+ return 1;
338
+ if (b.name === null)
339
+ return -1;
340
+ }
341
+ return cmpStr(a.name, b.name) || cmpStr(a.user_id, b.user_id);
342
+ }
343
+ /**
344
+ * The one team derivation shared by `roleplay team` and the roleplay manager view (PIT-10068): the
345
+ * coach bundle, its pass progress, the roleplay filters and the D18 order, so the text tool and the
346
+ * view always cut the same 50 assignments.
347
+ */
348
+ export async function loadTeamModel(client, instanceId, scopePref) {
349
+ const { bundle, requested } = await getCoachBundle(client, instanceId, scopePref);
350
+ // An older next-core ignores scope=instance and answers without `scope`: report what was applied.
351
+ const scope = bundle.scope === 'instance' && requested === 'instance' ? 'instance' : 'coach';
352
+ const sessions = (bundle.sessions ?? []).filter((s) => s.sessionType !== 'manager-video-practice');
353
+ const assignments = (bundle.assignments ?? []).filter((a) => a.contentType !== 'practice').sort(compareAssignments);
354
+ const warnings = [];
355
+ const sessionsCapped = sessions.length >= COACH_DATA_SESSION_CAP;
356
+ if (sessionsCapped) {
357
+ warnings.push(`The server returned its maximum of ${COACH_DATA_SESSION_CAP} sessions (newest first); older sessions are missing, so statuses and averages may be understated`);
358
+ }
359
+ const progress = await loadProgress(client, instanceId, bundle, scope, warnings);
360
+ let pairsWithoutProgress = 0;
361
+ if (progress) {
362
+ for (const a of assignments) {
363
+ for (const id of a.resolvedUserIds ?? [])
364
+ if (!progress.has(progressKey(a.id, id)))
365
+ pairsWithoutProgress += 1;
366
+ }
367
+ }
368
+ return {
369
+ scope,
370
+ requested,
371
+ coverage: { sessionsCapped, progress: progress ? 'full' : 'none', pairsWithoutProgress },
372
+ warnings,
373
+ assignments,
374
+ sessions,
375
+ progress,
376
+ };
225
377
  }
226
378
  /**
227
379
  * Assignment completion per rep, ported from the manager app: an admin gets every roleplay
228
380
  * assignment on the instance (`scope=instance`), anyone else the assignments they coach.
229
- * Statuses follow `deriveStatus`; raw sessions, evaluations and comments never leave.
381
+ * Statuses, session counts and best scores follow `deriveStatus` / `repStats` with the server's
382
+ * pass progress when it loads (PIT-9971); assignments and reps in D18 order (`loadTeamModel`).
383
+ * Raw sessions, evaluations and comments never leave.
230
384
  */
231
385
  export async function runRoleplayTeam(opts, deps = {}) {
232
386
  try {
@@ -235,11 +389,9 @@ export async function runRoleplayTeam(opts, deps = {}) {
235
389
  throw createCliError('VALIDATION', `--status must be one of: ${REP_STATUSES.join(', ')}`);
236
390
  }
237
391
  const client = clientFor(opts, deps);
238
- const { bundle, requested } = await getCoachBundle(client, instanceId);
239
- // An older next-core ignores scope=instance and answers without `scope`: report what was applied.
240
- const scope = bundle.scope === 'instance' && requested === 'instance' ? 'instance' : 'coach';
241
- const sessions = (bundle.sessions ?? []).filter((s) => s.sessionType !== 'manager-video-practice');
242
- const roleplayAssignments = (bundle.assignments ?? []).filter((a) => a.contentType !== 'practice');
392
+ const model = await loadTeamModel(client, instanceId);
393
+ const { scope, sessions, progress, coverage, warnings } = model;
394
+ const roleplayAssignments = model.assignments;
243
395
  const selected = opts.assignmentId ? roleplayAssignments.filter((a) => a.id === opts.assignmentId) : roleplayAssignments;
244
396
  if (opts.assignmentId && selected.length === 0) {
245
397
  throw createCliError('NOT_FOUND', `No roleplay assignment ${opts.assignmentId} in this ${scope === 'instance' ? 'instance' : 'coaching scope'}`);
@@ -248,10 +400,12 @@ export async function runRoleplayTeam(opts, deps = {}) {
248
400
  const shownAssignments = selected.slice(0, TEAM_MAX_ASSIGNMENTS);
249
401
  const assignments = shownAssignments.map((a) => {
250
402
  const own = assignmentSessions(sessions, a);
251
- const reps = (a.resolvedUserIds ?? []).map((id) => ({
252
- ...repStats(id, own, a, now),
403
+ const reps = (a.resolvedUserIds ?? [])
404
+ .map((id) => ({
405
+ ...repStats(id, own, a, now, progress?.get(progressKey(a.id, id)) ?? null),
253
406
  name: a.resolvedUserNames?.[id] ?? null,
254
- }));
407
+ }))
408
+ .sort(compareReps);
255
409
  const statusCounts = Object.fromEntries(REP_STATUSES.map((st) => [st, reps.filter((r) => r.status === st).length]));
256
410
  const filtered = opts.status ? reps.filter((r) => r.status === opts.status) : reps;
257
411
  const shownReps = filtered.slice(0, TEAM_MAX_REPS_PER_ASSIGNMENT);
@@ -273,14 +427,10 @@ export async function runRoleplayTeam(opts, deps = {}) {
273
427
  : {}),
274
428
  };
275
429
  });
276
- const capped = sessions.length >= COACH_DATA_SESSION_CAP;
277
- const warnings = [];
278
- if (capped) {
279
- warnings.push(`The server returned its maximum of ${COACH_DATA_SESSION_CAP} sessions (newest first); older sessions are missing, so statuses and averages may be understated`);
280
- }
281
430
  return ok({
282
431
  scope,
283
- complete: !capped,
432
+ complete: !coverage.sessionsCapped,
433
+ coverage,
284
434
  assignments,
285
435
  total_assignments: selected.length,
286
436
  returned: assignments.length,
@@ -295,8 +445,10 @@ export async function runRoleplayTeam(opts, deps = {}) {
295
445
  }
296
446
  }
297
447
  /**
298
- * The allowlist. Never the transcript, messages, contact name, graded snapshot, voice session,
299
- * recording or audio ids, or any video-evaluation field.
448
+ * The allowlist, shared by `roleplay session` and the manager view's scorecard. Never the transcript,
449
+ * messages, contact name, graded snapshot, voice session, recording or audio ids, or any
450
+ * video-evaluation field. The two scales (`grading_scale`, `max_score`) are numbers only (PIT-10068):
451
+ * legacy evaluations grade on 5, and a skill score is meaningless without its scale.
300
452
  */
301
453
  export function pickSessionDetail(id, data) {
302
454
  const evaluation = (data.evaluation ?? null);
@@ -316,10 +468,12 @@ export function pickSessionDetail(id, data) {
316
468
  summary: evaluation.summary ?? null,
317
469
  strengths: Array.isArray(evaluation.strengths) ? evaluation.strengths : [],
318
470
  improvements: Array.isArray(evaluation.improvements) ? evaluation.improvements : [],
471
+ grading_scale: scaleOrNull(evaluation.grading_scale),
319
472
  category_scores: categories.map((c) => ({
320
473
  skill_id: c.skillId ?? null,
321
474
  title: c.title ?? null,
322
475
  score: finiteOrNull(c.score),
476
+ max_score: scaleOrNull(c.max_score),
323
477
  feedback: c.feedback ?? null,
324
478
  })),
325
479
  }
@@ -385,6 +539,97 @@ export async function runRoleplayScenariosList(opts, deps = {}) {
385
539
  return fail(toCliError(e));
386
540
  }
387
541
  }
542
+ // ── learning paths (PIT-10113) ───────────────────────────────────────────────
543
+ const PATHS_BASE = '/core/api/protected/roleplay/paths';
544
+ const PATH_ASSIGNMENTS = '/core/api/protected/roleplay/path-assignments';
545
+ export const ROLEPLAY_PATH_STATUSES = ['published', 'draft', 'all'];
546
+ /** The trimmed path: counts, never the stage/step bodies. */
547
+ export function pickPath(p) {
548
+ return {
549
+ id: String(p.id ?? ''),
550
+ name: typeof p.name === 'string' ? p.name : '',
551
+ status: typeof p.status === 'string' ? p.status : '',
552
+ version: typeof p.version === 'number' ? p.version : 0,
553
+ stages: Array.isArray(p.stages) ? p.stages.length : 0,
554
+ steps: Array.isArray(p.steps) ? p.steps.length : 0,
555
+ default_passing_score: finiteOrNull(p.defaultPassingScore),
556
+ updated_at: typeof p.updatedAt === 'string' ? p.updatedAt : null,
557
+ };
558
+ }
559
+ /** next-core `GET roleplay/paths` (builder roles; 404 when paths are off or the caller is not a member). */
560
+ export async function fetchPaths(client, instanceId, status = 'published') {
561
+ const res = await client.get(PATHS_BASE, {
562
+ params: { instance_id: instanceId, status },
563
+ headers: { 'x-instance-id': instanceId },
564
+ });
565
+ const paths = res.data?.paths;
566
+ return Array.isArray(paths) ? paths : [];
567
+ }
568
+ export async function runRoleplayPathsList(opts, deps = {}) {
569
+ try {
570
+ const instanceId = requireInstance(opts, 'paths');
571
+ const status = opts.status ?? 'published';
572
+ if (!ROLEPLAY_PATH_STATUSES.includes(status)) {
573
+ throw createCliError('VALIDATION', `--status must be one of ${ROLEPLAY_PATH_STATUSES.join(', ')}`);
574
+ }
575
+ const client = clientFor(opts, deps);
576
+ const paths = (await fetchPaths(client, instanceId, status)).map(pickPath);
577
+ return ok({ paths, total_count: paths.length });
578
+ }
579
+ catch (e) {
580
+ if (isCliError(e))
581
+ return fail(e);
582
+ const err = toCliError(e);
583
+ if (err.code === 'NOT_FOUND') {
584
+ return fail(createCliError('NOT_FOUND', 'Learning paths are not enabled on this instance, or you are not a member of it', err.backendStatus));
585
+ }
586
+ return fail(err);
587
+ }
588
+ }
589
+ /** next-core `GET roleplay/path-assignments`: the records in the caller's view (admins get the membership keys). */
590
+ export async function fetchPathRecords(client, instanceId) {
591
+ const res = await client.get(PATH_ASSIGNMENTS, { params: { instance_id: instanceId }, headers: { 'x-instance-id': instanceId } });
592
+ const records = res.data?.records;
593
+ return Array.isArray(records) ? records : [];
594
+ }
595
+ // ── confirmed writes (PIT-10113): the three next-core calls, each with an Idempotency-Key ─────────────────────────
596
+ /** A stored answer replayed by next-core: 200 with `Idempotent-Replay: true`. */
597
+ function isReplay(res) {
598
+ const h = (res.headers ?? {});
599
+ const v = typeof h.get === 'function' ? h.get('idempotent-replay') : (h['idempotent-replay'] ?? h['Idempotent-Replay']);
600
+ return res.status === 200 && String(v).toLowerCase() === 'true';
601
+ }
602
+ export async function postCoachComment(client, args, idempotencyKey) {
603
+ const res = await client.post(`${COACH_BASE}/comments`, {
604
+ instanceId: args.instanceId,
605
+ sessionEntryId: args.sessionEntryId,
606
+ feedbackType: args.sessionEntryId ? 'session' : 'general',
607
+ assignmentId: args.assignmentId,
608
+ repUserId: args.repUserId,
609
+ comment: args.comment,
610
+ product: 'roleplay',
611
+ }, { headers: { 'x-instance-id': args.instanceId, 'Idempotency-Key': idempotencyKey } });
612
+ return { body: (res.data ?? {}), replayed: isReplay(res) };
613
+ }
614
+ export async function deleteCoachComment(client, args, idempotencyKey) {
615
+ const res = await client.delete(`${COACH_BASE}/comments/${encodeURIComponent(args.commentId)}`, {
616
+ params: { instance_id: args.instanceId },
617
+ headers: { 'x-instance-id': args.instanceId, 'Idempotency-Key': idempotencyKey },
618
+ });
619
+ return { replayed: isReplay(res) };
620
+ }
621
+ export async function postPathAssignment(client, instanceId, body, idempotencyKey) {
622
+ // exactly the six schema keys: the strict schema refuses any other (instance_id rides in the query)
623
+ const res = await client.post(PATH_ASSIGNMENTS, {
624
+ pathId: body.pathId,
625
+ groupIds: body.groupIds,
626
+ recipientUserIds: body.recipientUserIds,
627
+ managerIds: body.managerIds,
628
+ mandatory: body.mandatory,
629
+ dueDate: body.dueDate,
630
+ }, { params: { instance_id: instanceId }, headers: { 'x-instance-id': instanceId, 'Idempotency-Key': idempotencyKey } });
631
+ return { body: (res.data ?? {}), replayed: isReplay(res) };
632
+ }
388
633
  // ── commander ────────────────────────────────────────────────────────────────
389
634
  const ADMIN_NOTE = 'instance or org admins only';
390
635
  function withFilterFlags(cmd) {
@@ -445,5 +690,13 @@ export function createRoleplayCommand() {
445
690
  const globals = command.optsWithGlobals();
446
691
  emitEnvelope(await runRoleplayScenariosList({ ...options, quiet: globals.quiet }), globals);
447
692
  });
693
+ withAuthFlags(cmd.command('paths'))
694
+ .option('--instance-id <id>', 'Instance ID (required)')
695
+ .option('--status <status>', `Path status: ${ROLEPLAY_PATH_STATUSES.join(', ')} (default published)`)
696
+ .description('the instance learning paths: id, name, version, stage and step counts (instance admins and editors, org admins)')
697
+ .action(async (options, command) => {
698
+ const globals = command.optsWithGlobals();
699
+ emitEnvelope(await runRoleplayPathsList({ ...options, quiet: globals.quiet }), globals);
700
+ });
448
701
  return cmd;
449
702
  }