@pitcher/cli-scripts 3.1.1 → 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 (70) hide show
  1. package/README.md +39 -2
  2. package/dist/index.js +2 -0
  3. package/dist/src/commands/analytics.d.ts +77 -0
  4. package/dist/src/commands/analytics.js +309 -1
  5. package/dist/src/commands/dsrLanding.js +1 -1
  6. package/dist/src/commands/roleplay.d.ts +236 -0
  7. package/dist/src/commands/roleplay.js +702 -0
  8. package/dist/src/core/dsr-engagement.d.ts +152 -0
  9. package/dist/src/core/dsr-engagement.js +301 -0
  10. package/dist/src/core/dsr-visits.d.ts +50 -0
  11. package/dist/src/core/dsr-visits.js +191 -0
  12. package/dist/src/core/envelope.d.ts +1 -0
  13. package/dist/src/core/envelope.js +6 -1
  14. package/dist/src/core/errors.d.ts +3 -1
  15. package/dist/src/core/errors.js +2 -2
  16. package/dist/src/core/http.js +7 -5
  17. package/dist/src/core/ld.js +2 -1
  18. package/dist/src/core/me.d.ts +3 -0
  19. package/dist/src/core/me.js +10 -0
  20. package/dist/src/core/roleplay-stats.d.ts +120 -0
  21. package/dist/src/core/roleplay-stats.js +171 -0
  22. package/dist/src/mcp/apps/confirm/define.d.ts +66 -0
  23. package/dist/src/mcp/apps/confirm/define.js +208 -0
  24. package/dist/src/mcp/apps/confirm/hash.d.ts +4 -0
  25. package/dist/src/mcp/apps/confirm/hash.js +23 -0
  26. package/dist/src/mcp/apps/confirm/types.d.ts +96 -0
  27. package/dist/src/mcp/apps/confirm/types.js +15 -0
  28. package/dist/src/mcp/apps/contentPicker.d.ts +104 -0
  29. package/dist/src/mcp/apps/contentPicker.js +268 -0
  30. package/dist/src/mcp/apps/dsrEngagement.d.ts +194 -0
  31. package/dist/src/mcp/apps/dsrEngagement.js +737 -0
  32. package/dist/src/mcp/apps/registry.d.ts +14 -0
  33. package/dist/src/mcp/apps/registry.js +25 -0
  34. package/dist/src/mcp/apps/roleplayManager.d.ts +228 -0
  35. package/dist/src/mcp/apps/roleplayManager.js +606 -0
  36. package/dist/src/mcp/apps/roleplayReps.d.ts +79 -0
  37. package/dist/src/mcp/apps/roleplayReps.js +328 -0
  38. package/dist/src/mcp/apps/roleplayWrites.d.ts +6 -0
  39. package/dist/src/mcp/apps/roleplayWrites.js +509 -0
  40. package/dist/src/mcp/apps/text.d.ts +41 -0
  41. package/dist/src/mcp/apps/text.js +58 -0
  42. package/dist/src/mcp/apps/types.d.ts +23 -0
  43. package/dist/src/mcp/apps/types.js +12 -0
  44. package/dist/src/mcp/apps/writeConfirm.d.ts +3 -0
  45. package/dist/src/mcp/apps/writeConfirm.js +8 -0
  46. package/dist/src/mcp/groups.d.ts +42 -0
  47. package/dist/src/mcp/groups.js +89 -0
  48. package/dist/src/mcp/hosted.d.ts +36 -0
  49. package/dist/src/mcp/hosted.js +47 -1
  50. package/dist/src/mcp/index.d.ts +9 -3
  51. package/dist/src/mcp/index.js +9 -2
  52. package/dist/src/mcp/prompts/registry.js +8 -7
  53. package/dist/src/mcp/prompts/types.d.ts +3 -0
  54. package/dist/src/mcp/registry.d.ts +4 -0
  55. package/dist/src/mcp/registry.js +30 -23
  56. package/dist/src/mcp/renderPolicy.d.ts +2 -1
  57. package/dist/src/mcp/renderPolicy.js +10 -3
  58. package/dist/src/mcp/resources/analyticsCatalog.d.ts +1 -1
  59. package/dist/src/mcp/resources/analyticsCatalog.js +23 -5
  60. package/dist/src/mcp/resources/registry.js +2 -2
  61. package/dist/src/mcp/resources/types.d.ts +3 -0
  62. package/dist/src/mcp/result.d.ts +2 -1
  63. package/dist/src/mcp/result.js +8 -6
  64. package/dist/src/mcp/server.d.ts +62 -0
  65. package/dist/src/mcp/server.js +161 -10
  66. package/dist/src/mcp/tools/analytics.js +88 -3
  67. package/dist/src/mcp/tools/roleplay.d.ts +2 -0
  68. package/dist/src/mcp/tools/roleplay.js +135 -0
  69. package/dist/src/mcp/tools/types.d.ts +35 -0
  70. package/package.json +7 -1
@@ -0,0 +1,191 @@
1
+ /**
2
+ * One DSR viewer's event timeline (`/analytics/canvas-details/sessions`) → visits, and per visit the
3
+ * sections, files and pages viewed with their time (PIT-9589). The visit rule is the Sharing
4
+ * Analytics app's (`packages/apps/canvas-analytics/src/lib/reconstructSessions.ts`): a visit opens
5
+ * on an `Entered` of the canvas itself and is keyed by its `event_pair_id`; its engaged time is the
6
+ * sum of its focus segments (canvas Entered → Exited). Change them together.
7
+ *
8
+ * Item time: the sum of the item's `Exited` durations (`forhowlong`, seconds) in the visit. An
9
+ * `Entered` with no `Exited` (the unload beacon was dropped, PIT-5833) or an `Exited` reporting 0 is
10
+ * estimated from the gap to the next event of the visit, as the app's timeline does, and flagged
11
+ * `estimated`.
12
+ */
13
+ const ITEM_TYPES = { Section: 'section', File: 'file', Page: 'page' };
14
+ // The page index is the LAST path segment, `<fileId>/page/<n>` or the older `<fileId>/<n>`, 0-based.
15
+ const PAGE_INDEX_SUFFIX = /\/(?:page\/)?(\d+)$/;
16
+ const ms = (ts) => new Date(ts).getTime();
17
+ const seconds = (from, to) => Math.max(0, Math.round((ms(to) - ms(from)) / 1000));
18
+ function sortPriority(e, anchorId) {
19
+ const isAnchor = e.entity_id === anchorId;
20
+ if (!isAnchor && e.action === 'Exited')
21
+ return 1;
22
+ if (isAnchor && e.action === 'Exited')
23
+ return 2;
24
+ if (isAnchor && e.action === 'Entered')
25
+ return 3;
26
+ if (!isAnchor && e.action === 'Entered')
27
+ return 4;
28
+ return 5;
29
+ }
30
+ /** `reconstructSessions`' grouping: events from an anchor `Entered` until the next one, by pair key. */
31
+ export function groupVisits(events, anchorId) {
32
+ const sorted = [...events].sort((a, b) => a.timestamp.localeCompare(b.timestamp) || sortPriority(a, anchorId) - sortPriority(b, anchorId));
33
+ const groups = new Map();
34
+ let key = null;
35
+ let legacy = 0;
36
+ for (const e of sorted) {
37
+ if (e.entity_id === anchorId && e.action === 'Entered') {
38
+ key = e.event_pair_id ? `pair:${e.event_pair_id}` : `legacy:${++legacy}`;
39
+ }
40
+ if (!key)
41
+ continue;
42
+ const g = groups.get(key);
43
+ if (g)
44
+ g.push(e);
45
+ else
46
+ groups.set(key, [e]);
47
+ }
48
+ return Array.from(groups.values()).filter((g) => g.length > 0);
49
+ }
50
+ /** `computeEngagementDuration`: the wall-clock of each canvas focus segment, summed. */
51
+ export function engagedSeconds(events, anchorId) {
52
+ let total = 0;
53
+ let start = null;
54
+ let last = null;
55
+ const flush = (end) => {
56
+ if (start && end)
57
+ total += seconds(start, end);
58
+ start = null;
59
+ last = null;
60
+ };
61
+ for (const e of events) {
62
+ if (e.entity_id === anchorId) {
63
+ if (e.action === 'Entered') {
64
+ flush(last);
65
+ start = e.timestamp;
66
+ last = e.timestamp;
67
+ }
68
+ else if (e.action === 'Exited') {
69
+ flush(e.timestamp);
70
+ }
71
+ }
72
+ else if (start) {
73
+ last = e.timestamp;
74
+ }
75
+ }
76
+ flush(last);
77
+ return total;
78
+ }
79
+ function itemsOf(events, anchorId) {
80
+ const items = new Map();
81
+ const open = new Map(); // entity → indexes of unmatched Entered
82
+ const item = (e) => {
83
+ const type = ITEM_TYPES[e.original_event_name];
84
+ if (!type || e.entity_id === anchorId)
85
+ return null;
86
+ let it = items.get(e.entity_id);
87
+ if (!it) {
88
+ const pageIdx = type === 'page' ? PAGE_INDEX_SUFFIX.exec(e.entity_id) : null;
89
+ it = {
90
+ type,
91
+ id: e.entity_id,
92
+ name: e.entity_name || e.entity_type || e.entity_id,
93
+ ...(pageIdx ? { page_number: Number(pageIdx[1]) + 1 } : {}),
94
+ views: 0,
95
+ seconds: 0,
96
+ estimated: false,
97
+ };
98
+ items.set(e.entity_id, it);
99
+ }
100
+ return it;
101
+ };
102
+ const gapToNext = (i) => (i + 1 < events.length ? seconds(events[i].timestamp, events[i + 1].timestamp) : 0);
103
+ events.forEach((e, i) => {
104
+ const it = item(e);
105
+ if (!it)
106
+ return;
107
+ if (e.action === 'Entered') {
108
+ it.views += 1;
109
+ open.set(e.entity_id, [...(open.get(e.entity_id) ?? []), i]);
110
+ }
111
+ else if (e.action === 'Exited') {
112
+ const pending = open.get(e.entity_id) ?? [];
113
+ const enteredAt = pending.shift();
114
+ open.set(e.entity_id, pending);
115
+ if (enteredAt === undefined)
116
+ it.views = Math.max(it.views, 1); // an Exited-only view (adblock): still a view
117
+ const reported = typeof e.duration === 'number' && e.duration > 0 ? e.duration : 0;
118
+ if (reported > 0) {
119
+ it.seconds += reported;
120
+ }
121
+ else if (enteredAt !== undefined) {
122
+ it.seconds += seconds(events[enteredAt].timestamp, e.timestamp);
123
+ it.estimated = true;
124
+ }
125
+ }
126
+ });
127
+ // Entered with no Exited in the visit: the gap to the next event.
128
+ for (const [id, pending] of open) {
129
+ const it = items.get(id);
130
+ for (const i of pending) {
131
+ it.seconds += gapToNext(i);
132
+ it.estimated = true;
133
+ }
134
+ }
135
+ return Array.from(items.values());
136
+ }
137
+ export function summarizeVisits(events, canvasId) {
138
+ return groupVisits(events, canvasId).map((g, i) => ({
139
+ visit: i + 1,
140
+ started_at: g[0].timestamp,
141
+ ended_at: g[g.length - 1].timestamp,
142
+ engaged_seconds: engagedSeconds(g, canvasId),
143
+ items: itemsOf(g, canvasId),
144
+ }));
145
+ }
146
+ /** RFC 4180 cell; a leading = + - @ is prefixed with ' so a spreadsheet never runs it as a formula. */
147
+ export function csvCell(v) {
148
+ let s = v == null ? '' : String(v);
149
+ if (/^[=+\-@\t\r]/.test(s))
150
+ s = `'${s}`;
151
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
152
+ }
153
+ export const VISITS_CSV_HEADER = [
154
+ 'viewer',
155
+ 'visit',
156
+ 'visit_started_at',
157
+ 'visit_engaged_seconds',
158
+ 'item_type',
159
+ 'item_name',
160
+ 'page_number',
161
+ 'item_id',
162
+ 'views',
163
+ 'seconds',
164
+ 'estimated',
165
+ ];
166
+ export function visitsToCsv(viewers) {
167
+ const lines = [VISITS_CSV_HEADER.join(',')];
168
+ for (const { viewer, visits } of viewers) {
169
+ for (const v of visits) {
170
+ const items = v.items.length ? v.items : [null];
171
+ for (const it of items) {
172
+ lines.push([
173
+ viewer,
174
+ v.visit,
175
+ v.started_at,
176
+ v.engaged_seconds,
177
+ it?.type ?? '',
178
+ it?.name ?? '',
179
+ it?.page_number ?? '',
180
+ it?.id ?? '',
181
+ it?.views ?? '',
182
+ it?.seconds ?? '',
183
+ it ? String(it.estimated) : '',
184
+ ]
185
+ .map(csvCell)
186
+ .join(','));
187
+ }
188
+ }
189
+ }
190
+ return lines.join('\n') + '\n';
191
+ }
@@ -6,6 +6,7 @@ export interface Envelope<T> {
6
6
  code: ErrorCodeName;
7
7
  exitCode: number;
8
8
  message: string;
9
+ backendStatus?: number;
9
10
  };
10
11
  warnings?: string[];
11
12
  }
@@ -4,7 +4,12 @@ export function ok(data, warnings) {
4
4
  export function fail(error, warnings) {
5
5
  return {
6
6
  ok: false,
7
- error: { code: error.code, exitCode: error.exitCode, message: error.message },
7
+ error: {
8
+ code: error.code,
9
+ exitCode: error.exitCode,
10
+ message: error.message,
11
+ ...(error.backendStatus ? { backendStatus: error.backendStatus } : {}),
12
+ },
8
13
  ...(warnings?.length ? { warnings } : {}),
9
14
  };
10
15
  }
@@ -13,6 +13,8 @@ export interface CliError {
13
13
  code: ErrorCodeName;
14
14
  exitCode: number;
15
15
  message: string;
16
+ /** HTTP status of the backend's error answer, when the failure came from a backend response (PIT-10114 audit). */
17
+ backendStatus?: number;
16
18
  }
17
- export declare function createCliError(code: ErrorCodeName, message: string): CliError;
19
+ export declare function createCliError(code: ErrorCodeName, message: string, backendStatus?: number): CliError;
18
20
  export declare function isCliError(e: unknown): e is CliError;
@@ -7,8 +7,8 @@ export const ERROR_CODES = {
7
7
  NOT_FOUND: 5,
8
8
  CONFLICT: 6,
9
9
  };
10
- export function createCliError(code, message) {
11
- return { __cliError: true, code, exitCode: ERROR_CODES[code], message };
10
+ export function createCliError(code, message, backendStatus) {
11
+ return { __cliError: true, code, exitCode: ERROR_CODES[code], message, ...(backendStatus ? { backendStatus } : {}) };
12
12
  }
13
13
  export function isCliError(e) {
14
14
  if (typeof e !== 'object' || e === null)
@@ -242,15 +242,17 @@ export function toCliError(e) {
242
242
  const apiMessage = extractApiMessage(data, err.message || 'Request failed');
243
243
  const status = err.response?.status;
244
244
  if (typeof status === 'number') {
245
+ // backendStatus: the backend answered (PIT-10114). A backend 4xx also maps to VALIDATION, so the code alone
246
+ // cannot tell a local check from a backend answer.
245
247
  if (status === 401 || status === 403)
246
- return createCliError('AUTH', apiMessage);
248
+ return createCliError('AUTH', apiMessage, status);
247
249
  if (status === 404)
248
- return createCliError('NOT_FOUND', apiMessage);
250
+ return createCliError('NOT_FOUND', apiMessage, status);
249
251
  if (status === 409)
250
- return createCliError('CONFLICT', apiMessage);
252
+ return createCliError('CONFLICT', apiMessage, status);
251
253
  if (status >= 400 && status < 500)
252
- return createCliError('VALIDATION', apiMessage);
253
- return createCliError('NETWORK', apiMessage);
254
+ return createCliError('VALIDATION', apiMessage, status);
255
+ return createCliError('NETWORK', apiMessage, status);
254
256
  }
255
257
  if (err.isAxiosError)
256
258
  return createCliError('NETWORK', apiMessage);
@@ -423,7 +423,8 @@ export function toLdCliError(e) {
423
423
  : s && s >= 400 && s < 500
424
424
  ? 'VALIDATION'
425
425
  : 'NETWORK';
426
- return createCliError(code, msg);
426
+ // the L&D backend answered: keep its status for the PIT-10114 audit (`backendStatus`)
427
+ return createCliError(code, msg, typeof s === 'number' ? s : undefined);
427
428
  }
428
429
  return toCliError(e);
429
430
  }
@@ -0,0 +1,3 @@
1
+ import type { AxiosInstance } from 'axios';
2
+ /** The caller's Pitcher user id (Django `GET /api/v1/users/me/`), as a string. PIT-10113: the actor of a confirmed write. */
3
+ export declare function getMeId(client: AxiosInstance): Promise<string>;
@@ -0,0 +1,10 @@
1
+ import { createCliError } from './errors.js';
2
+ /** The caller's Pitcher user id (Django `GET /api/v1/users/me/`), as a string. PIT-10113: the actor of a confirmed write. */
3
+ export async function getMeId(client) {
4
+ const res = await client.get('/api/v1/users/me/');
5
+ const id = res.data?.id;
6
+ const s = typeof id === 'number' ? String(id) : typeof id === 'string' ? id : '';
7
+ if (!/^[0-9]{1,9}$/.test(s))
8
+ throw createCliError('AUTH', 'could not read your Pitcher user id');
9
+ return s;
10
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The AI Roleplay manager-app's per-rep rules, ported 1:1 so the MCP team tool and the manager
3
+ * app give the same answer on the same data (PIT-9200). Sources, in ng-production-apps
4
+ * `pitcher-coach/manager-app/src/`: `lib/session-score.ts` (reading a score), `lib/stats.ts`
5
+ * (best, mean, trend, `normalizeScore`), `lib/status.ts` (`deriveStatus`),
6
+ * `hooks/useManagerData.ts` (`getAssignmentSessions`, `getRepStats`),
7
+ * `components/SkillBreakdown.tsx` (`latestSkills`) and `lib/api.ts` (`loadAssignmentProgress`,
8
+ * ported as `loadTeamModel` in `commands/roleplay.ts`). Since PIT-9971 status, session count, best
9
+ * score and last session come from the server's pass progress when it is known. Change them together.
10
+ *
11
+ * The one rule every aggregate here obeys: a session is unscored iff `overallScore` is not a
12
+ * finite number. An unscored session is never read as 0.
13
+ */
14
+ export interface RoleplaySessionLike {
15
+ userId?: string;
16
+ scenarioId?: string;
17
+ timestamp?: string;
18
+ overallScore?: number | null;
19
+ durationMs?: number | null;
20
+ evaluation?: {
21
+ grading_scale?: number | string | null;
22
+ category_scores?: Array<{
23
+ id?: string;
24
+ skillId?: string;
25
+ title?: string;
26
+ score?: number;
27
+ max_score?: number | string | null;
28
+ }>;
29
+ } | null;
30
+ }
31
+ export interface RoleplayAssignmentLike {
32
+ scenarioId: string;
33
+ type: 'mandatory' | 'optional' | string;
34
+ passingScore: number;
35
+ dueDate: string | null;
36
+ createdAt: string;
37
+ resolvedUserIds: string[];
38
+ }
39
+ export type RepStatus = 'passed' | 'in_progress' | 'not_started' | 'overdue' | 'needs_coaching';
40
+ /** next-core's pass progress for one (assignment, rep) pair over ALL its sessions (PIT-9971). */
41
+ export interface AssignmentProgressLike {
42
+ passed: boolean;
43
+ passSource: 'stored' | 'live' | null;
44
+ attempts: number;
45
+ bestScore: number | null;
46
+ latestAt: string | null;
47
+ }
48
+ /** The session's score, or null when it has none (`toFiniteOrNull`). */
49
+ export declare function sessionScore(session: RoleplaySessionLike): number | null;
50
+ /** Mean of the finite values, rounded, or null when none are finite (`meanOfFinite`). */
51
+ export declare function meanOfFinite(values: readonly (number | null | undefined)[]): number | null;
52
+ /** Largest finite value, or null (`maxOfFinite`). */
53
+ export declare function maxOfFinite(values: readonly (number | null | undefined)[]): number | null;
54
+ /** Earliest-to-latest window delta over the scored entries only (`scoreTrend`). */
55
+ export declare function scoreTrend(scores: readonly (number | null | undefined)[], windowFor: (scoredCount: number) => number): number | null;
56
+ export declare const avgScore: (sessions: RoleplaySessionLike[]) => number | null;
57
+ export declare const bestScore: (sessions: RoleplaySessionLike[]) => number | null;
58
+ export declare const scoredCount: (sessions: RoleplaySessionLike[]) => number;
59
+ /** Up to 3 sessions per end, capped at half the scored series (`calcTrend`). */
60
+ export declare function calcTrend(sessions: RoleplaySessionLike[]): number | null;
61
+ /**
62
+ * `deriveStatus`: the raw best score is compared with `passingScore` as stored, as the manager app
63
+ * does. `overallScore` is 0..100 on every evaluation, legacy ones included (their `grading_scale` of 5
64
+ * applies to the category scores only), so never normalise it here.
65
+ * With `progress`, passed and started come from the server; only `needs_coaching` (a judgement
66
+ * on the recent trend) reads the capped list. Without it, everything comes from the list.
67
+ */
68
+ export declare function deriveStatus(sessions: RoleplaySessionLike[], assignment: RoleplayAssignmentLike, now?: Date, progress?: AssignmentProgressLike | null): RepStatus;
69
+ /** `getAssignmentSessions`: same scenario, a rep of the assignment, on or after it was created. */
70
+ export declare function assignmentSessions<T extends RoleplaySessionLike>(sessions: T[], assignment: RoleplayAssignmentLike): T[];
71
+ export interface RepStats {
72
+ user_id: string;
73
+ sessions: number;
74
+ scored_sessions: number;
75
+ best_score: number | null;
76
+ avg_score: number | null;
77
+ trend: number | null;
78
+ status: RepStatus;
79
+ last_session_at: string | null;
80
+ total_duration_ms: number;
81
+ /** Progress counts more attempts than the list holds: avg_score, scored_sessions and trend cover recent sessions only. */
82
+ recent_only: boolean;
83
+ pass_source: 'stored' | 'live' | null;
84
+ }
85
+ /**
86
+ * `getRepStats` for one rep of one assignment, without the raw sessions. With `progress`, sessions,
87
+ * best score and last session come from the server; average, scored count and trend stay on the list.
88
+ */
89
+ export declare function repStats(userId: string, sessions: RoleplaySessionLike[], assignment: RoleplayAssignmentLike, now?: Date, progress?: AssignmentProgressLike | null): RepStats;
90
+ /** A grading scale: a finite positive number, or a string `Number()` turns into one; else null (D28). */
91
+ export declare function scaleOrNull(v: unknown): number | null;
92
+ /** `normalizeScore` (manager-app `lib/stats.ts`): a score on `max` as 0..100; no usable max = as is. */
93
+ export declare function normalizeScore(score: number, max?: number | null): number;
94
+ export interface LatestSkill {
95
+ skillId: string;
96
+ title: string;
97
+ score: number;
98
+ }
99
+ /**
100
+ * The Rep panel's skills (`SkillBreakdown`): the NEWEST session with at least one category score,
101
+ * scored or not, its categories normalised on `max_score ?? grading_scale ?? 100`, weakest first.
102
+ * Categories without a finite score are skipped; null when none remain. Never averaged across
103
+ * sessions, so 5-point and 100-point evaluations are never mixed.
104
+ */
105
+ export declare function latestSkills<T extends RoleplaySessionLike>(sessions: T[]): {
106
+ session: T;
107
+ skills: LatestSkill[];
108
+ } | null;
109
+ export interface SkillAverage {
110
+ skill_id: string;
111
+ title: string;
112
+ avg_score: number | null;
113
+ scored_sessions: number;
114
+ }
115
+ /**
116
+ * Per-skill means over the sessions' `evaluation.category_scores`, keyed by `skillId` (the
117
+ * Skills Registry id). A legacy category without one falls back to its own `id`, which is stable
118
+ * within one scenario; an entry with neither, or with no finite score, is skipped.
119
+ */
120
+ export declare function skillAverages(sessions: RoleplaySessionLike[]): SkillAverage[];
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The AI Roleplay manager-app's per-rep rules, ported 1:1 so the MCP team tool and the manager
3
+ * app give the same answer on the same data (PIT-9200). Sources, in ng-production-apps
4
+ * `pitcher-coach/manager-app/src/`: `lib/session-score.ts` (reading a score), `lib/stats.ts`
5
+ * (best, mean, trend, `normalizeScore`), `lib/status.ts` (`deriveStatus`),
6
+ * `hooks/useManagerData.ts` (`getAssignmentSessions`, `getRepStats`),
7
+ * `components/SkillBreakdown.tsx` (`latestSkills`) and `lib/api.ts` (`loadAssignmentProgress`,
8
+ * ported as `loadTeamModel` in `commands/roleplay.ts`). Since PIT-9971 status, session count, best
9
+ * score and last session come from the server's pass progress when it is known. Change them together.
10
+ *
11
+ * The one rule every aggregate here obeys: a session is unscored iff `overallScore` is not a
12
+ * finite number. An unscored session is never read as 0.
13
+ */
14
+ /** The session's score, or null when it has none (`toFiniteOrNull`). */
15
+ export function sessionScore(session) {
16
+ const v = session.overallScore;
17
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
18
+ }
19
+ /** Mean of the finite values, rounded, or null when none are finite (`meanOfFinite`). */
20
+ export function meanOfFinite(values) {
21
+ const finite = values.filter((n) => typeof n === 'number' && Number.isFinite(n));
22
+ if (finite.length === 0)
23
+ return null;
24
+ return Math.round(finite.reduce((a, b) => a + b, 0) / finite.length);
25
+ }
26
+ /** Largest finite value, or null (`maxOfFinite`). */
27
+ export function maxOfFinite(values) {
28
+ const finite = values.filter((n) => typeof n === 'number' && Number.isFinite(n));
29
+ return finite.length === 0 ? null : Math.max(...finite);
30
+ }
31
+ /** Earliest-to-latest window delta over the scored entries only (`scoreTrend`). */
32
+ export function scoreTrend(scores, windowFor) {
33
+ const finite = scores.filter((n) => typeof n === 'number' && Number.isFinite(n));
34
+ if (finite.length < 2)
35
+ return null;
36
+ const n = Math.min(windowFor(finite.length), finite.length);
37
+ if (n < 1)
38
+ return null;
39
+ const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
40
+ return Math.round(mean(finite.slice(-n)) - mean(finite.slice(0, n)));
41
+ }
42
+ const byTimestamp = (a, b) => new Date(a.timestamp ?? 0).getTime() - new Date(b.timestamp ?? 0).getTime();
43
+ export const avgScore = (sessions) => meanOfFinite(sessions.map(sessionScore));
44
+ export const bestScore = (sessions) => maxOfFinite(sessions.map(sessionScore));
45
+ export const scoredCount = (sessions) => sessions.filter((s) => sessionScore(s) !== null).length;
46
+ /** Up to 3 sessions per end, capped at half the scored series (`calcTrend`). */
47
+ export function calcTrend(sessions) {
48
+ const sorted = [...sessions].sort(byTimestamp);
49
+ return scoreTrend(sorted.map(sessionScore), (count) => Math.min(3, Math.floor(count / 2)));
50
+ }
51
+ /**
52
+ * `deriveStatus`: the raw best score is compared with `passingScore` as stored, as the manager app
53
+ * does. `overallScore` is 0..100 on every evaluation, legacy ones included (their `grading_scale` of 5
54
+ * applies to the category scores only), so never normalise it here.
55
+ * With `progress`, passed and started come from the server; only `needs_coaching` (a judgement
56
+ * on the recent trend) reads the capped list. Without it, everything comes from the list.
57
+ */
58
+ export function deriveStatus(sessions, assignment, now = new Date(), progress) {
59
+ const isOverdue = assignment.type === 'mandatory' && !!assignment.dueDate && new Date(assignment.dueDate) < now;
60
+ if (progress) {
61
+ if (progress.passed)
62
+ return 'passed';
63
+ if (progress.attempts === 0)
64
+ return isOverdue ? 'overdue' : 'not_started';
65
+ if (isOverdue)
66
+ return 'overdue';
67
+ const trend = calcTrend(sessions);
68
+ if (scoredCount(sessions) >= 3 && trend != null && trend <= 0)
69
+ return 'needs_coaching';
70
+ return 'in_progress';
71
+ }
72
+ if (sessions.length === 0)
73
+ return isOverdue ? 'overdue' : 'not_started';
74
+ const best = bestScore(sessions);
75
+ if (best != null && best >= assignment.passingScore)
76
+ return 'passed';
77
+ if (isOverdue)
78
+ return 'overdue';
79
+ const trend = calcTrend(sessions);
80
+ if (scoredCount(sessions) >= 3 && trend != null && trend <= 0)
81
+ return 'needs_coaching';
82
+ return 'in_progress';
83
+ }
84
+ /** `getAssignmentSessions`: same scenario, a rep of the assignment, on or after it was created. */
85
+ export function assignmentSessions(sessions, assignment) {
86
+ const createdAt = new Date(assignment.createdAt).getTime();
87
+ return sessions.filter((s) => s.scenarioId === assignment.scenarioId &&
88
+ typeof s.userId === 'string' &&
89
+ assignment.resolvedUserIds.includes(s.userId) &&
90
+ new Date(s.timestamp ?? 0).getTime() >= createdAt);
91
+ }
92
+ /**
93
+ * `getRepStats` for one rep of one assignment, without the raw sessions. With `progress`, sessions,
94
+ * best score and last session come from the server; average, scored count and trend stay on the list.
95
+ */
96
+ export function repStats(userId, sessions, assignment, now = new Date(), progress) {
97
+ const own = sessions.filter((s) => s.userId === userId).sort(byTimestamp);
98
+ const lastListed = own.length > 0 ? (own[own.length - 1].timestamp ?? null) : null;
99
+ return {
100
+ user_id: userId,
101
+ sessions: progress ? progress.attempts : own.length,
102
+ scored_sessions: scoredCount(own),
103
+ best_score: progress ? progress.bestScore : bestScore(own),
104
+ avg_score: avgScore(own),
105
+ trend: calcTrend(own),
106
+ status: deriveStatus(own, assignment, now, progress),
107
+ last_session_at: progress ? progress.latestAt : lastListed,
108
+ total_duration_ms: own.reduce((sum, s) => sum + (s.durationMs ?? 0), 0),
109
+ recent_only: !!progress && progress.attempts > own.length,
110
+ pass_source: progress?.passSource ?? null,
111
+ };
112
+ }
113
+ /** A grading scale: a finite positive number, or a string `Number()` turns into one; else null (D28). */
114
+ export function scaleOrNull(v) {
115
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : Number.NaN;
116
+ return Number.isFinite(n) && n > 0 ? n : null;
117
+ }
118
+ /** `normalizeScore` (manager-app `lib/stats.ts`): a score on `max` as 0..100; no usable max = as is. */
119
+ export function normalizeScore(score, max) {
120
+ if (!max || max <= 0)
121
+ return score;
122
+ return Math.min(100, Math.round((score / max) * 100));
123
+ }
124
+ /**
125
+ * The Rep panel's skills (`SkillBreakdown`): the NEWEST session with at least one category score,
126
+ * scored or not, its categories normalised on `max_score ?? grading_scale ?? 100`, weakest first.
127
+ * Categories without a finite score are skipped; null when none remain. Never averaged across
128
+ * sessions, so 5-point and 100-point evaluations are never mixed.
129
+ */
130
+ export function latestSkills(sessions) {
131
+ const session = [...sessions].sort((a, b) => byTimestamp(b, a)).find((s) => s.evaluation?.category_scores?.length);
132
+ if (!session)
133
+ return null;
134
+ const scale = scaleOrNull(session.evaluation?.grading_scale);
135
+ const skills = [];
136
+ for (const c of session.evaluation?.category_scores ?? []) {
137
+ const key = c.skillId || c.title;
138
+ if (!key || typeof c.score !== 'number' || !Number.isFinite(c.score))
139
+ continue;
140
+ skills.push({ skillId: key, title: c.title ?? key, score: normalizeScore(c.score, scaleOrNull(c.max_score) ?? scale ?? 100) });
141
+ }
142
+ if (skills.length === 0)
143
+ return null;
144
+ return { session, skills: skills.sort((a, b) => a.score - b.score) };
145
+ }
146
+ /**
147
+ * Per-skill means over the sessions' `evaluation.category_scores`, keyed by `skillId` (the
148
+ * Skills Registry id). A legacy category without one falls back to its own `id`, which is stable
149
+ * within one scenario; an entry with neither, or with no finite score, is skipped.
150
+ */
151
+ export function skillAverages(sessions) {
152
+ const bySkill = new Map();
153
+ for (const s of sessions) {
154
+ if (sessionScore(s) === null)
155
+ continue;
156
+ for (const c of s.evaluation?.category_scores ?? []) {
157
+ const key = c.skillId || c.id;
158
+ if (!key || typeof c.score !== 'number' || !Number.isFinite(c.score))
159
+ continue;
160
+ const entry = bySkill.get(key) ?? { title: c.title ?? key, scores: [] };
161
+ entry.scores.push(c.score);
162
+ bySkill.set(key, entry);
163
+ }
164
+ }
165
+ return Array.from(bySkill.entries()).map(([skill_id, { title, scores }]) => ({
166
+ skill_id,
167
+ title,
168
+ avg_score: meanOfFinite(scores),
169
+ scored_sessions: scores.length,
170
+ }));
171
+ }
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod';
2
+ import type { ToolGroup } from '../../groups.js';
3
+ import type { InjectedAuth, ToolDescriptor } from '../../tools/types.js';
4
+ import { type CommitResult, type Proposal, type ProposalClaims, type ProposalPreview } from './types.js';
5
+ export declare const WRITE_CONFIRM_VIEW = "write-confirm";
6
+ /** Every commit carries the hash-bound, display-only summary first (spec 6.4a). */
7
+ export declare const confirmTextShape: z.ZodString;
8
+ export interface CommitArgsBase {
9
+ confirmText: string;
10
+ instanceId: string;
11
+ [key: string]: unknown;
12
+ }
13
+ export interface ConfirmedWriteDef<P, A extends CommitArgsBase, R> {
14
+ /** stable action id bound into the token, e.g. 'roleplay.comment.create' */
15
+ action: string;
16
+ group: ToolGroup;
17
+ propose: {
18
+ name: string;
19
+ title: string;
20
+ description: string;
21
+ inputShape: Record<string, z.ZodType>;
22
+ };
23
+ /** argsShape must start with `confirmText` (JSON Schema property order, for hosts that show the schema) */
24
+ commit: {
25
+ name: string;
26
+ title: string;
27
+ description: string;
28
+ argsShape: Record<string, z.ZodType>;
29
+ };
30
+ /** Canonical commit args: ONLY the keys of commit.argsShape, every default materialized. Pure and idempotent. */
31
+ normalize(args: Record<string, unknown>): A;
32
+ /** Validate + read; returns the commit args and the human preview. Throws CliError on refusal. */
33
+ prepare(input: P, auth: InjectedAuth, deps?: unknown): Promise<{
34
+ args: A;
35
+ actorUserId: string;
36
+ preview: ProposalPreview;
37
+ }>;
38
+ /** The write. Sends `Idempotency-Key: idempotencyKey`. Called only after the token verified. */
39
+ execute(args: A, idempotencyKey: string, auth: InjectedAuth, deps?: unknown): Promise<{
40
+ result: R;
41
+ replayed: boolean;
42
+ }>;
43
+ resultText(r: R, status: CommitResult['status']): string;
44
+ /** the commit (and propose) arg the confirm view may edit; it re-proposes with the new value */
45
+ editableArg?: keyof A & string;
46
+ /** propose text budget; tests only (default PROPOSAL_TEXT_MAX_BYTES) */
47
+ textMaxBytes?: number;
48
+ }
49
+ /** Copies exactly the shape's keys out of the handler's merged object (never a spread: injected keys stay out). */
50
+ export declare function pickArgs(opts: Record<string, unknown>, shape: Record<string, z.ZodType>): Record<string, unknown>;
51
+ /** Control characters removed (a third-party string never adds a line to a preview). */
52
+ export declare function stripControls(s: string): string;
53
+ /** A third-party string as a preview shows it: controls removed, at most `max` characters (never a split code point). */
54
+ export declare function previewName(s: unknown, max?: number): string;
55
+ /** "a, b and N more": at most `max` names, the rest counted (never cut mid-name). */
56
+ export declare function nameList(names: string[], max: number): string;
57
+ /** The proposal id seeds one key: `mcp-<action>-<jti>` (ASCII, at most 80 characters). */
58
+ export declare const idempotencyKeyOf: (c: Pick<ProposalClaims, "action" | "jti">) => string;
59
+ /** The commit call as the host_prompt tail prints it: confirmText first, the rest canonical, proposalToken last. */
60
+ export declare function printCommitArgs(args: Record<string, unknown>, proposalToken: string): string;
61
+ /** The propose tool's model text (spec 8.2). The body appears once: as a line in app mode, inside the call otherwise. */
62
+ export declare function proposalText(p: Proposal): string;
63
+ /** The commit tool's model text (spec 8.3). */
64
+ export declare function commitText(status: CommitResult['status'], resultText: string): string;
65
+ /** The propose + commit descriptors of one confirmed write (spec 6.4). Both are `access: 'write'` (PIT-10114 rule 3). */
66
+ export declare function defineConfirmedWrite<P, A extends CommitArgsBase, R>(def: ConfirmedWriteDef<P, A, R>): [ToolDescriptor, ToolDescriptor];