fraim 2.0.342 → 2.0.344

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 (30) hide show
  1. package/dist/registry/scripts/artifact-paths.js +108 -0
  2. package/dist/registry/scripts/learning/completion-state.js +205 -5
  3. package/dist/registry/scripts/learning-usage/local-runtime.js +217 -0
  4. package/dist/src/cli/commands/init-project.js +2 -12
  5. package/dist/src/cli/commands/sync.js +36 -162
  6. package/dist/src/cli/setup/ide-invocation-surfaces.js +3 -3
  7. package/dist/src/cli/setup/user-level-sync.js +3 -3
  8. package/dist/src/cli/utils/agent-adapters.js +12 -12
  9. package/dist/src/cli/utils/fraim-gitignore.js +28 -3
  10. package/dist/src/cli/utils/pack-home.js +10 -0
  11. package/dist/src/cli/utils/project-catalog-cleanup.js +62 -0
  12. package/dist/src/cli/utils/remote-sync.js +26 -2
  13. package/dist/src/config/persona-hiring.js +7 -1
  14. package/dist/src/core/ai-mentor.js +42 -11
  15. package/dist/src/core/config-loader.js +4 -0
  16. package/dist/src/core/fraim-config-schema.generated.js +22 -0
  17. package/dist/src/core/handoff-contracts.js +8 -3
  18. package/dist/src/core/utils/local-registry-resolver.js +33 -57
  19. package/dist/src/fraim/db-service.js +127 -118
  20. package/dist/src/local-mcp-server/artifact-retention-cleanup.js +79 -6
  21. package/dist/src/local-mcp-server/learning-context-builder.js +101 -105
  22. package/dist/src/local-mcp-server/learning-source-record.js +122 -0
  23. package/dist/src/local-mcp-server/learning-usage-attestation.js +66 -56
  24. package/dist/src/local-mcp-server/learning-usage-command.js +5 -4
  25. package/dist/src/local-mcp-server/stdio-server.js +10 -7
  26. package/dist/src/local-mcp-server/usage-collector.js +2 -0
  27. package/dist/src/mcp/tool-schemas.js +5 -1
  28. package/dist/src/services/mcp-service.js +6 -4
  29. package/dist/src/services/usage-analytics-service.js +1 -0
  30. package/package.json +3 -1
@@ -32,7 +32,7 @@ class AIMentor {
32
32
  }
33
33
  }
34
34
  if (args.status === 'starting') {
35
- return await this.generateStartingMessage(workflow, args.currentPhase, args.skipIncludes);
35
+ return await this.generateStartingMessage(workflow, args.currentPhase, args.skipIncludes, args.requestInstructions);
36
36
  }
37
37
  else if (args.status === 'complete') {
38
38
  return await this.generateCompletionMessage(workflow, args.currentPhase, args.findings, args.evidence, args.skipIncludes);
@@ -65,6 +65,9 @@ class AIMentor {
65
65
  const unique = Array.from(new Set(matches));
66
66
  throw new Error(`Unresolved include directives in ${context}: ${unique.join(', ')}`);
67
67
  }
68
+ buildPhaseArrivalInstruction(jobName, phaseId) {
69
+ return `## On Arrival\nBefore starting any work in this phase, call \`seekMentoring({ jobName: "${jobName}", issueNumber: "<issue_number>", currentPhase: "${phaseId}", status: "starting" })\`.\n\n`;
70
+ }
68
71
  buildReportBackFooter(jobName, phaseId, phaseFlow) {
69
72
  if (!phaseFlow)
70
73
  return '';
@@ -99,6 +102,9 @@ class AIMentor {
99
102
  getCompactPhaseAuthority() {
100
103
  return AIMentor.COMPACT_PHASE_AUTHORITY;
101
104
  }
105
+ getEvidenceInitialization() {
106
+ return '## Initialize the Work Record\nAfter resolving the workspace and owning home, before substantive work, read and follow `get_fraim_file({ path: "delivery/initialize-evidence.md" })`. Resume the existing run record when continuing work.';
107
+ }
102
108
  buildPhasedJobOverview(workflow) {
103
109
  const metadataPhases = Object.keys(workflow.metadata.phases || {});
104
110
  const initialPhase = workflow.metadata.initialPhase || metadataPhases[0] || Array.from(workflow.phases.keys())[0] || 'starting';
@@ -120,9 +126,10 @@ class AIMentor {
120
126
  + ' `get_fraim_file({ path: "skills/engineering/set-up-workspace.md" })`'
121
127
  + ' to work on the issue\'s branch when a repository is configured, or in place otherwise.');
122
128
  }
129
+ lines.push('', this.getEvidenceInitialization());
123
130
  return lines.join('\n');
124
131
  }
125
- async generateStartingMessage(workflow, phaseId, skipIncludes) {
132
+ async generateStartingMessage(workflow, phaseId, skipIncludes, requestInstructions) {
126
133
  const entityType = 'Job';
127
134
  if (workflow.isSimple) {
128
135
  const message = `Starting ${entityType}: ${workflow.metadata.name}\n\n${workflow.overview}`;
@@ -134,26 +141,42 @@ class AIMentor {
134
141
  };
135
142
  }
136
143
  const isVeryFirstCall = phaseId === 'starting';
137
- let targetPhase = isVeryFirstCall ? (workflow.metadata.initialPhase || 'starting') : phaseId;
144
+ const initialPhase = workflow.metadata.initialPhase || Object.keys(workflow.metadata.phases || {})[0] || Array.from(workflow.phases.keys())[0] || 'starting';
145
+ let targetPhase = isVeryFirstCall ? initialPhase : phaseId;
138
146
  const targetDefinition = workflow.metadata.phases?.[targetPhase];
147
+ // Named initial phases and scheduled recurrence entry have no preceding
148
+ // completion response supplying their instructions.
149
+ const deliverInstructions = isVeryFirstCall || phaseId === initialPhase
150
+ || (0, resolve_phase_edge_1.isRecurrencePhase)(targetDefinition)
151
+ || (0, resolve_phase_edge_1.findRecurrenceForMember)(workflow.metadata.phases, phaseId)?.role === 'recur'
152
+ || requestInstructions === true;
139
153
  if ((0, resolve_phase_edge_1.isRecurrencePhase)(targetDefinition)) {
140
154
  targetPhase = isVeryFirstCall ? targetDefinition.setup.phase : targetDefinition.recur.phase;
141
155
  }
142
156
  let message = `### Current Phase: ${targetPhase}\n\n`;
143
- let instructions = workflow.phases.get(targetPhase);
144
- if (instructions) {
145
- instructions = skipIncludes ? instructions : await this.resolveIncludes(instructions, workflow.path);
146
- message += instructions;
157
+ if (isVeryFirstCall) {
158
+ message += this.buildPhaseArrivalInstruction(workflow.metadata.name, targetPhase);
147
159
  }
148
- else {
149
- message += `No specific instructions found for phase: ${targetPhase}`;
160
+ if (deliverInstructions) {
161
+ let instructions = workflow.phases.get(targetPhase);
162
+ if (instructions) {
163
+ // The proxy survives compaction with a warmed skill cache. An
164
+ // explicit recovery must restore nested skill bodies as well.
165
+ instructions = skipIncludes ? instructions : requestInstructions === true
166
+ ? await (0, include_resolver_1.resolveIncludes)(instructions, this.resolver, workflow.path)
167
+ : await this.resolveIncludes(instructions, workflow.path);
168
+ message += instructions;
169
+ }
170
+ else {
171
+ message += `No specific instructions found for phase: ${targetPhase}`;
172
+ }
150
173
  }
151
174
  const recurrenceMember = (0, resolve_phase_edge_1.findRecurrenceForMember)(workflow.metadata.phases, targetPhase);
152
175
  const phaseFlow = recurrenceMember
153
176
  ? { onSuccess: undefined, onFailure: recurrenceMember.node[recurrenceMember.role].onFailure }
154
177
  : ((0, resolve_phase_edge_1.isRecurrencePhase)(workflow.metadata.phases?.[targetPhase]) ? undefined : workflow.metadata.phases?.[targetPhase]);
155
178
  message += this.buildReportBackFooter(workflow.metadata.name, targetPhase, phaseFlow);
156
- if (!skipIncludes) {
179
+ if (deliverInstructions && !skipIncludes) {
157
180
  this.assertNoUnresolvedIncludes(message, `${workflow.metadata.name}:${targetPhase} (starting)`);
158
181
  }
159
182
  return {
@@ -229,6 +252,7 @@ class AIMentor {
229
252
  }
230
253
  else if (nextPhaseId) {
231
254
  message += `Moving to the next phase: **${nextPhaseId}**.\n\n`;
255
+ message += this.buildPhaseArrivalInstruction(workflow.metadata.name, nextPhaseId);
232
256
  let nextInstructions = workflow.phases.get(nextPhaseId);
233
257
  if (nextInstructions) {
234
258
  nextInstructions = skipIncludes ? nextInstructions : await this.resolveIncludes(nextInstructions, workflow.path);
@@ -275,6 +299,13 @@ class AIMentor {
275
299
  ?? (!(0, resolve_phase_edge_1.isRecurrencePhase)(phaseMeta) ? phaseMeta?.onFailure : undefined), (0, resolve_phase_edge_1.resolveDiscriminant)(findings, evidence)) || phaseId)
276
300
  : phaseId;
277
301
  let message = `### Current Phase: ${targetPhaseId}\n\n`;
302
+ // Issue #1935: a `failure` commits a completion for the phase being left, so
303
+ // the phase resolved above is entered with no open instance and needs its own
304
+ // `status: "starting"` call, for a back-edge and for the self-retry alike.
305
+ // `incomplete` commits nothing and never leaves the phase, so it gets none.
306
+ if (status === 'failure') {
307
+ message += this.buildPhaseArrivalInstruction(workflow.metadata.name, targetPhaseId);
308
+ }
278
309
  let instructions = workflow.phases.get(targetPhaseId);
279
310
  if (instructions) {
280
311
  instructions = skipIncludes ? instructions : await this.resolveIncludes(instructions, workflow.path);
@@ -329,7 +360,7 @@ class AIMentor {
329
360
  if (!job)
330
361
  return null;
331
362
  if (job.isSimple) {
332
- return { overview: job.overview, isSimple: true };
363
+ return { overview: `${job.overview}\n\n${this.getEvidenceInitialization()}`, isSimple: true };
333
364
  }
334
365
  return {
335
366
  overview: this.buildPhasedJobOverview(job),
@@ -169,6 +169,10 @@ function normalizeFraimConfig(config, options = {}) {
169
169
  if (config.learning) {
170
170
  mergedConfig.learning = config.learning;
171
171
  }
172
+ if (config.artifactPaths !== undefined) {
173
+ // Preserve configuration; the shared artifact resolver owns validation and defaults.
174
+ mergedConfig.artifactPaths = config.artifactPaths;
175
+ }
172
176
  if (config.competitors && typeof config.competitors === 'object') {
173
177
  mergedConfig.competitors = config.competitors;
174
178
  }
@@ -230,6 +230,23 @@ exports.FRAIM_CONFIG_SCHEMA = {
230
230
  }
231
231
  }
232
232
  },
233
+ "artifactPaths": {
234
+ "kind": "object",
235
+ "properties": {
236
+ "evidence": {
237
+ "kind": "string"
238
+ },
239
+ "rawLearnings": {
240
+ "kind": "string"
241
+ },
242
+ "learningRetrospectives": {
243
+ "kind": "string"
244
+ },
245
+ "learningArchive": {
246
+ "kind": "string"
247
+ }
248
+ }
249
+ },
233
250
  "customer-communication": {
234
251
  "kind": "object",
235
252
  "properties": {
@@ -693,6 +710,11 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
693
710
  "learning.usage.firingLogLimit",
694
711
  "learning.usage.offerLogLimit",
695
712
  "learning.usage.retirementOfferThreshold",
713
+ "artifactPaths",
714
+ "artifactPaths.evidence",
715
+ "artifactPaths.rawLearnings",
716
+ "artifactPaths.learningRetrospectives",
717
+ "artifactPaths.learningArchive",
696
718
  "customer-communication",
697
719
  "customer-communication.productName",
698
720
  "customer-communication.productUrl",
@@ -297,11 +297,16 @@ function upsertPhaseStarted(marker, phaseId, timestamp) {
297
297
  startedPhases[phaseId] = timestamp;
298
298
  return { startedPhases };
299
299
  }
300
- /** Builds the warn-only notice for any phase completed without an on-arrival start. */
301
- function buildPhaseStartWarning(phaseId) {
302
- return (`⚠️ **Phase-start notice**: phase \`${phaseId}\` completed without an on-arrival ` +
300
+ /** Builds the warn-only notice for any phase completed without an on-arrival start.
301
+ * When nextPhase is supplied, a forward-looking instruction is appended so the agent
302
+ * knows to call seekMentoring({status:"starting"}) before beginning the next phase. */
303
+ function buildPhaseStartWarning(phaseId, nextPhase) {
304
+ const base = (`⚠️ **Phase-start notice**: phase \`${phaseId}\` completed without an on-arrival ` +
303
305
  `\`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` call in this session. ` +
304
306
  `The missing call was flagged, but this completion remains accepted.\n\n`);
307
+ if (!nextPhase)
308
+ return base;
309
+ return base + `Before starting any work in **${nextPhase}**, call \`seekMentoring({ currentPhase: "${nextPhase}", status: "starting" })\` immediately.\n\n`;
305
310
  }
306
311
  function isAbsoluteHttpUrl(url) {
307
312
  if (typeof url !== 'string' || !url)
@@ -72,6 +72,17 @@ class LocalRegistryResolver {
72
72
  }
73
73
  return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, ...parts);
74
74
  }
75
+ /**
76
+ * Machine catalogs own synced content. Legacy project catalogs are a fallback
77
+ * only when that machine catalog is absent, so removed entries cannot revive
78
+ * from an old project copy. Explicit user-level roots remain self-contained.
79
+ */
80
+ getSyncedCatalogRoot(role, type) {
81
+ if (this.effectiveFraimDir)
82
+ return (0, path_1.join)(this.effectiveFraimDir, role, type);
83
+ const machineRoot = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), role, type);
84
+ return (0, fs_1.existsSync)(machineRoot) ? machineRoot : this.getFraimPath(role, type);
85
+ }
75
86
  /**
76
87
  * Check if a local override exists for the given path
77
88
  */
@@ -102,16 +113,16 @@ class LocalRegistryResolver {
102
113
  (0, path_1.join)(this.managerCacheRoot, dir),
103
114
  (0, path_1.join)(this.orgCacheRoot, dir),
104
115
  (0, path_1.join)(this.workspaceRoot, 'registry', dir),
105
- // Installed projects have categorized job stubs in the synced roles,
106
- // without a source registry checkout. Keep authored overrides first.
107
- ...(type === 'jobs' ? [this.getFraimPath('ai-employee', dir), this.getFraimPath('ai-manager', dir)] : []),
116
+ // Installed projects discover categorized capabilities in machine catalogs.
117
+ this.getSyncedCatalogRoot('ai-employee', dir),
118
+ ...(type !== 'skills' ? [this.getSyncedCatalogRoot('ai-manager', dir)] : []),
108
119
  ];
109
120
  for (const root of searchRoots) {
110
121
  const found = this.searchFileRecursively(root, baseName);
111
122
  if (!found)
112
123
  continue;
113
- const syncedRole = ['ai-employee', 'ai-manager'].find(role => root === this.getFraimPath(role, dir));
114
- if (syncedRole)
124
+ const syncedRole = ['ai-employee', 'ai-manager'].find(role => root === this.getSyncedCatalogRoot(role, dir));
125
+ if (syncedRole && type !== 'skills')
115
126
  return `${dir}/${syncedRole}/${found.slice(root.length + 1).replace(/\\/g, '/')}`;
116
127
  // Convert absolute back to a registry-relative path.
117
128
  const rel = found.replace(/\\/g, '/');
@@ -157,67 +168,32 @@ class LocalRegistryResolver {
157
168
  return this.getFraimPath('personalized-employee', normalized);
158
169
  }
159
170
  /**
160
- * Get the full path to a locally synced FRAIM file when available.
161
- * Skills and rules are synced under role-based folders:
162
- * - skills/* -> fraim/ai-employee/skills/*
163
- * - rules/* -> fraim/ai-employee/rules/*
171
+ * Resolve synced content from machine catalogs, retaining legacy project
172
+ * catalogs only when the corresponding machine catalog is unavailable.
164
173
  */
165
174
  getSyncedFilePath(path) {
166
175
  const normalizedPath = path.replace(/\\/g, '/').replace(/^\/+/, '');
167
- // 1. Workflows: workflows/[role]/path -> fraim/[role]/workflows/path
168
- if (normalizedPath.startsWith('workflows/')) {
169
- const parts = normalizedPath.split('/');
176
+ const parts = normalizedPath.split('/');
177
+ const type = parts[0];
178
+ if (type === 'jobs' || type === 'workflows') {
170
179
  if (parts.length >= 3 && (parts[1] === 'ai-employee' || parts[1] === 'ai-manager')) {
171
- const role = parts[1];
172
- const subPath = parts.slice(2).join('/');
173
- return this.getFraimPath(role, 'workflows', subPath);
180
+ return (0, path_1.join)(this.getSyncedCatalogRoot(parts[1], type), ...parts.slice(2));
174
181
  }
175
- // Fallback: Try ai-employee and ai-manager if no role prefix
176
- const subPath = normalizedPath.substring('workflows/'.length);
177
- const employeePath = this.getFraimPath('ai-employee', 'workflows', subPath);
178
- if (fs.existsSync(employeePath))
179
- return employeePath;
180
- const managerPath = this.getFraimPath('ai-manager', 'workflows', subPath);
181
- if (fs.existsSync(managerPath))
182
- return managerPath;
183
- // Fallback for non-role-prefixed direct workspace paths
184
- return this.getFraimPath(normalizedPath);
185
- }
186
- // 2. Jobs: jobs/[role]/path -> fraim/[role]/jobs/path
187
- if (normalizedPath.startsWith('jobs/')) {
188
- const parts = normalizedPath.split('/');
189
- if (parts.length >= 3 && (parts[1] === 'ai-employee' || parts[1] === 'ai-manager')) {
190
- const role = parts[1];
191
- const subPath = parts.slice(2).join('/');
192
- return this.getFraimPath(role, 'jobs', subPath);
182
+ for (const role of ['ai-employee', 'ai-manager']) {
183
+ const candidate = (0, path_1.join)(this.getSyncedCatalogRoot(role, type), ...parts.slice(1));
184
+ if ((0, fs_1.existsSync)(candidate))
185
+ return candidate;
193
186
  }
194
- // Fallback: Try ai-employee and ai-manager if no role prefix
195
- const subPath = normalizedPath.substring('jobs/'.length);
196
- const employeePath = this.getFraimPath('ai-employee', 'jobs', subPath);
197
- if (fs.existsSync(employeePath))
198
- return employeePath;
199
- const managerPath = this.getFraimPath('ai-manager', 'jobs', subPath);
200
- if (fs.existsSync(managerPath))
201
- return managerPath;
202
- // Fallback
187
+ // Retain direct workspace job/workflow files authored outside the catalogs.
203
188
  return this.getFraimPath(normalizedPath);
204
189
  }
205
- // 3. Rules: [role]/rules/path -> fraim/[role]/rules/path
206
190
  if (normalizedPath.includes('/rules/')) {
207
- const role = normalizedPath.includes('/ai-manager/') ? 'ai-manager' : 'ai-employee';
208
- // Extract the part after "rules/"
209
- const rulesIdx = normalizedPath.indexOf('rules/');
210
- const subPath = normalizedPath.substring(rulesIdx + 'rules/'.length);
211
- return this.getFraimPath(role, 'rules', subPath);
212
- }
213
- // 4. Skills: skills/path -> fraim/ai-employee/skills/path (default to ai-employee)
214
- if (normalizedPath.startsWith('skills/')) {
215
- const subPath = normalizedPath.substring('skills/'.length);
216
- return this.getFraimPath('ai-employee', 'skills', subPath);
191
+ const role = parts.includes('ai-manager') ? 'ai-manager' : 'ai-employee';
192
+ const subPath = normalizedPath.substring(normalizedPath.indexOf('rules/') + 'rules/'.length);
193
+ return (0, path_1.join)(this.getSyncedCatalogRoot(role, 'rules'), subPath);
217
194
  }
218
- // 5. Rules: rules/path -> fraim/ai-employee/rules/path (default to ai-employee)
219
- if (normalizedPath.startsWith('rules/')) {
220
- return this.getFraimPath('ai-employee', normalizedPath);
195
+ if (type === 'skills' || type === 'rules') {
196
+ return (0, path_1.join)(this.getSyncedCatalogRoot('ai-employee', type), ...parts.slice(1));
221
197
  }
222
198
  return null;
223
199
  }
@@ -579,7 +555,7 @@ class LocalRegistryResolver {
579
555
  }
580
556
  const syncedLocalContent = this.readSyncedLocalFile(path);
581
557
  if (syncedLocalContent !== null) {
582
- // Synced baseline (fraim/ai-employee), not personalized.
558
+ // Synced machine baseline, not personalized.
583
559
  return {
584
560
  content: syncedLocalContent,
585
561
  source: 'local',
@@ -1090,6 +1090,22 @@ class FraimDbService {
1090
1090
  });
1091
1091
  return latest?.createdAt ?? null;
1092
1092
  }
1093
+ /** Keep the earliest observed start per execution before grouping into time buckets. */
1094
+ distinctJobStartStages() {
1095
+ return [
1096
+ { $sort: { createdAt: 1, _id: 1 } },
1097
+ { $group: {
1098
+ _id: { $cond: [
1099
+ { $and: [{ $eq: ['$type', 'job'] }, { $ne: [{ $ifNull: ['$jobId', ''] }, ''] }] },
1100
+ { userId: '$userId', name: '$name', jobId: '$jobId' },
1101
+ // Preserve legacy event counts when no execution ID is available.
1102
+ '$_id',
1103
+ ] },
1104
+ event: { $first: '$$ROOT' },
1105
+ } },
1106
+ { $replaceRoot: { newRoot: '$event' } },
1107
+ ];
1108
+ }
1093
1109
  /**
1094
1110
  * Get top components by usage count
1095
1111
  */
@@ -1106,18 +1122,19 @@ class FraimDbService {
1106
1122
  if (types && types.length > 0) {
1107
1123
  matchStage.type = { $in: types };
1108
1124
  }
1109
- // When counting job events, exclude explicit complete events so each run is
1110
- // counted once. A completed run emits both start and complete; without this
1111
- // filter the dashboard would show double the runs count vs the runs list.
1112
- // Old-path events (no args.action field) pass through because $ne includes
1113
- // documents where the field is absent.
1125
+ // Job instruction reloads are continuation events, not new executions.
1126
+ // Preserve other component usage and legacy job loads without action metadata.
1114
1127
  if (!types || types.includes('job')) {
1115
- matchStage['args.action'] = { $ne: 'complete' };
1128
+ matchStage.$or = [
1129
+ { type: { $ne: 'job' } },
1130
+ { 'args.action': { $ne: 'complete' }, 'args.isReload': { $ne: true } },
1131
+ ];
1116
1132
  }
1117
1133
  const pipeline = [
1118
1134
  {
1119
1135
  $match: matchStage
1120
1136
  },
1137
+ ...this.distinctJobStartStages(),
1121
1138
  {
1122
1139
  $group: {
1123
1140
  _id: { name: '$name', type: '$type' },
@@ -1250,6 +1267,7 @@ class FraimDbService {
1250
1267
  // Exclude explicit complete events so each run is counted once (at start).
1251
1268
  // Old-path events have no args.action and are included by $ne.
1252
1269
  'args.action': { $ne: 'complete' },
1270
+ 'args.isReload': { $ne: true },
1253
1271
  createdAt: this.buildCreatedAtMatch(timeWindow)
1254
1272
  };
1255
1273
  if (userId) {
@@ -1277,6 +1295,7 @@ class FraimDbService {
1277
1295
  }
1278
1296
  const pipeline = [
1279
1297
  { $match: matchStage },
1298
+ ...this.distinctJobStartStages(),
1280
1299
  {
1281
1300
  $group: {
1282
1301
  _id: {
@@ -1837,133 +1856,123 @@ class FraimDbService {
1837
1856
  const collection = this.db.collection(this.usageEventsCollectionName);
1838
1857
  const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
1839
1858
  const createdAtMatch = { $gte: startDate, ...(endDate ? { $lte: endDate } : {}) };
1840
- // Check if we have explicit job lifecycle events
1841
- const explicitMatch = { type: 'job', 'args.action': 'start', createdAt: createdAtMatch };
1842
- if (jobName)
1843
- explicitMatch.name = jobName;
1844
- if (userId)
1845
- explicitMatch.userId = userId;
1846
- if (sanitizedRepo)
1847
- explicitMatch.repoIdentifier = sanitizedRepo;
1848
- const hasExplicit = await collection.countDocuments(explicitMatch) > 0;
1849
- // Use explicit events if available, fall back to mentoring events for legacy data
1850
- const matchStage = hasExplicit
1851
- ? { type: 'job', 'args.action': { $in: ['start', 'complete'] }, createdAt: createdAtMatch }
1852
- : { type: 'mentoring', createdAt: createdAtMatch };
1859
+ // Mentoring carries issue context that is not known when instructions load.
1860
+ const matchStage = { type: { $in: ['job', 'mentoring'] }, createdAt: createdAtMatch };
1853
1861
  if (jobName)
1854
1862
  matchStage.name = jobName;
1855
1863
  if (userId)
1856
1864
  matchStage.userId = userId;
1857
1865
  if (sanitizedRepo)
1858
1866
  matchStage.repoIdentifier = sanitizedRepo;
1859
- const pipeline = [
1867
+ const isStart = { $and: [
1868
+ { $eq: ['$type', 'job'] }, { $eq: ['$args.action', 'start'] },
1869
+ { $ne: [{ $ifNull: ['$args.isReload', false] }, true] },
1870
+ ] };
1871
+ const isLegacyLoad = { $and: [
1872
+ { $eq: ['$type', 'job'] }, { $eq: [{ $ifNull: ['$args.action', null] }, null] },
1873
+ { $ne: [{ $ifNull: ['$args.isReload', false] }, true] },
1874
+ ] };
1875
+ const isExplicitComplete = { $and: [{ $eq: ['$type', 'job'] }, { $eq: ['$args.action', 'complete'] }] };
1876
+ const isLegacyComplete = { $and: [
1877
+ { $eq: ['$type', 'mentoring'] }, { $eq: ['$args.status', 'complete'] },
1878
+ { $eq: ['$args.nextPhase', null] },
1879
+ ] };
1880
+ const executions = await collection.aggregate([
1860
1881
  { $match: matchStage },
1861
- { $sort: { createdAt: 1 } },
1862
- {
1863
- $group: {
1864
- _id: { jobName: '$name', jobId: '$jobId' },
1865
- startTime: { $min: '$createdAt' },
1866
- completeTime: {
1867
- $max: {
1868
- $cond: [
1869
- { $or: [
1870
- { $eq: ['$args.action', 'complete'] },
1871
- { $eq: ['$args.nextPhase', null] }
1872
- ] },
1873
- '$createdAt', null
1874
- ]
1875
- }
1876
- },
1877
- hasStart: { $first: { $cond: [true, true, false] } },
1878
- hasComplete: {
1879
- $max: {
1880
- $cond: [
1881
- { $or: [
1882
- { $eq: ['$args.action', 'complete'] },
1883
- { $eq: ['$args.nextPhase', null] }
1884
- ] },
1885
- true, false
1886
- ]
1887
- }
1888
- },
1889
- eventCount: { $sum: 1 }
1890
- }
1891
- },
1892
1882
  {
1893
1883
  $group: {
1894
- _id: '$_id.jobName',
1895
- totalStarted: { $sum: { $cond: ['$hasStart', 1, 0] } },
1896
- totalCompleted: { $sum: { $cond: ['$hasComplete', 1, 0] } },
1897
- completionDurations: {
1898
- $push: {
1899
- $cond: [
1900
- { $and: ['$hasStart', '$hasComplete', { $ne: ['$startTime', null] }, { $ne: ['$completeTime', null] }] },
1901
- { $subtract: ['$completeTime', '$startTime'] },
1902
- null
1903
- ]
1904
- }
1905
- },
1906
- // Track last phase for abandoned jobs (started but not completed)
1907
- abandonedPhases: {
1908
- $push: {
1909
- $cond: [
1910
- { $and: ['$hasStart', { $not: '$hasComplete' }] },
1911
- '$finalPhase',
1912
- null
1913
- ]
1914
- }
1915
- }
1916
- }
1917
- },
1918
- {
1919
- $project: {
1920
- jobName: '$_id',
1921
- totalStarted: 1,
1922
- totalCompleted: 1,
1923
- totalAbandoned: { $subtract: ['$totalStarted', '$totalCompleted'] },
1924
- completionRate: {
1925
- $cond: [
1926
- { $gt: ['$totalStarted', 0] },
1927
- { $divide: ['$totalCompleted', '$totalStarted'] },
1928
- 0
1929
- ]
1930
- },
1931
- avgCompletionMs: {
1932
- $cond: [
1933
- { $gt: [{ $size: '$completionDurations' }, 0] },
1934
- { $avg: '$completionDurations' },
1935
- null
1936
- ]
1937
- },
1938
- medianIdx: {
1939
- $cond: [
1940
- { $gt: [{ $size: '$completionDurations' }, 0] },
1941
- { $floor: { $divide: [{ $size: '$completionDurations' }, 2] } },
1942
- null
1943
- ]
1884
+ _id: {
1885
+ jobName: '$name', userId: '$userId',
1886
+ execution: { $ifNull: ['$jobId', { $concat: ['sess_', { $ifNull: ['$sessionId', { $toString: '$_id' }] }] }] },
1944
1887
  },
1945
- completionDurations: 1
1888
+ firstEventTime: { $min: '$createdAt' },
1889
+ startTime: { $min: { $cond: [isStart, '$createdAt', null] } },
1890
+ completeTime: { $max: { $cond: [isExplicitComplete, '$createdAt', null] } },
1891
+ legacyCompleteTime: { $max: { $cond: [isLegacyComplete, '$createdAt', null] } },
1892
+ hasStart: { $max: { $cond: [isStart, true, false] } },
1893
+ hasLegacyLoad: { $max: { $cond: [isLegacyLoad, true, false] } },
1894
+ hasJobLifecycle: { $max: { $cond: [
1895
+ { $and: [{ $eq: ['$type', 'job'] }, { $in: ['$args.action', ['start', 'complete']] }] }, true, false,
1896
+ ] } },
1897
+ hasMentoring: { $max: { $cond: [{ $eq: ['$type', 'mentoring'] }, true, false] } },
1898
+ legacyAmbiguous: { $max: { $cond: [
1899
+ { $and: [isStart, { $or: [
1900
+ { $ne: [{ $ifNull: ['$args.isReload', null] }, false] },
1901
+ { $eq: [{ $ifNull: ['$jobId', ''] }, ''] },
1902
+ ] }] }, true, false,
1903
+ ] } },
1904
+ repositories: { $addToSet: '$repoIdentifier' },
1905
+ issues: { $addToSet: '$args.issueNumber' },
1946
1906
  }
1947
1907
  }
1948
- ];
1949
- const results = await collection.aggregate(pipeline).toArray();
1950
- return results.map(result => {
1951
- // Calculate median from sorted durations
1952
- const durations = (result.completionDurations || []).filter((d) => typeof d === 'number').sort((a, b) => a - b);
1953
- const medianMs = durations.length > 0 ? durations[Math.floor(durations.length / 2)] : undefined;
1908
+ ]).toArray();
1909
+ const grouped = new Map();
1910
+ for (const execution of executions) {
1911
+ // A reload or completion alone does not establish a start in this window.
1912
+ // Keep old load/mentoring estimates, clearly labeled, without rewriting history.
1913
+ const estimated = !execution.hasStart && (execution.hasLegacyLoad || (!execution.hasJobLifecycle && execution.hasMentoring));
1914
+ if (!execution.hasStart && !estimated)
1915
+ continue;
1916
+ const name = execution._id.jobName;
1917
+ const result = grouped.get(name) || { count: 0, completed: 0, known: 0, legacy: 0, conflicts: 0, workItems: new Set(), durations: [] };
1918
+ grouped.set(name, result);
1919
+ result.count++;
1920
+ if (estimated || execution.legacyAmbiguous)
1921
+ result.legacy++;
1922
+ const repositories = new Set((execution.repositories || [])
1923
+ .filter((value) => typeof value === 'string' && value.trim().length > 0)
1924
+ .map((value) => (0, git_utils_1.sanitizeRepoIdentifier)(value)));
1925
+ const issues = new Set((execution.issues || [])
1926
+ .filter((value) => (typeof value === 'string' && value.trim().length > 0)
1927
+ || (typeof value === 'number' && Number.isFinite(value)))
1928
+ .map((value) => String(value).trim()));
1929
+ if (repositories.size === 1 && issues.size === 1) {
1930
+ result.known++;
1931
+ result.workItems.add(JSON.stringify([[...repositories][0], [...issues][0], name]));
1932
+ }
1933
+ else if (repositories.size > 1 || issues.size > 1) {
1934
+ result.conflicts++;
1935
+ }
1936
+ // Modern executions require explicit completion; legacy mentoring fallback
1937
+ // requires a complete status and an explicitly terminal nextPhase.
1938
+ const completedAt = execution.completeTime || (estimated ? execution.legacyCompleteTime : null);
1939
+ if (completedAt) {
1940
+ result.completed++;
1941
+ const startedAt = execution.startTime || execution.firstEventTime;
1942
+ const duration = new Date(completedAt).getTime() - new Date(startedAt).getTime();
1943
+ if (duration >= 0)
1944
+ result.durations.push(duration);
1945
+ }
1946
+ }
1947
+ return [...grouped].map(([name, result]) => {
1948
+ const durations = result.durations.sort((a, b) => a - b);
1949
+ const notes = [];
1950
+ if (result.legacy)
1951
+ notes.push(`${result.legacy} legacy execution identities are estimates; historical reloads cannot be distinguished reliably.`);
1952
+ if (result.count > result.known)
1953
+ notes.push(`${result.count - result.known} executions have unknown work-item identity; unique work items count only known identities.`);
1954
+ if (result.conflicts)
1955
+ notes.push(`${result.conflicts} executions have conflicting work-item metadata and are counted as unknown.`);
1956
+ const completionRate = result.completed / result.count;
1954
1957
  return {
1955
- jobName: result.jobName,
1956
- totalRequested: result.totalStarted,
1957
- totalStarted: result.totalStarted,
1958
- totalCompleted: result.totalCompleted,
1959
- totalAbandoned: result.totalStarted - result.totalCompleted,
1958
+ jobName: name,
1959
+ executionCount: result.count,
1960
+ uniqueWorkItemCount: result.workItems.size,
1961
+ executionsWithKnownWorkItem: result.known,
1962
+ executionsWithUnknownWorkItem: result.count - result.known,
1963
+ legacyAmbiguousExecutionCount: result.legacy,
1964
+ identityNote: notes.length ? notes.join(' ') : null,
1965
+ totalRequested: result.count,
1966
+ totalStarted: result.count,
1967
+ totalCompleted: result.completed,
1968
+ totalAbandoned: result.count - result.completed,
1960
1969
  startRate: 1.0,
1961
- completionRate: result.completionRate,
1962
- overallRate: result.completionRate,
1963
- averageCompletionTime: result.avgCompletionMs ?? undefined,
1964
- medianCompletionTime: medianMs,
1970
+ completionRate,
1971
+ overallRate: completionRate,
1972
+ averageCompletionTime: durations.length ? durations.reduce((sum, duration) => sum + duration, 0) / durations.length : undefined,
1973
+ medianCompletionTime: durations.length ? durations[Math.floor(durations.length / 2)] : undefined,
1965
1974
  commonAbandonmentPhases: [],
1966
- timeRange: { start: startDate, end: new Date() }
1975
+ timeRange: { start: startDate, end: endDate || new Date() }
1967
1976
  };
1968
1977
  });
1969
1978
  }