fraim 2.0.339 → 2.0.341
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/registry/scripts/learning/completion-state.js +161 -0
- package/dist/src/cli/commands/cleanup-artifacts.js +2 -2
- package/dist/src/core/ai-mentor.js +1 -1
- package/dist/src/core/handoff-contracts.js +165 -37
- package/dist/src/core/proposal-set-contract.js +214 -0
- package/dist/src/core/run-recovery-state.js +9 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +224 -20
- package/dist/src/local-mcp-server/learning-usage-analysis.js +35 -19
- package/dist/src/local-mcp-server/learning-usage-command.js +13 -14
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +59 -0
- package/dist/src/local-mcp-server/learning-usage-events.js +63 -57
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +235 -79
- package/dist/src/local-mcp-server/learning-usage-verify.js +13 -26
- package/dist/src/local-mcp-server/stdio-server.js +295 -112
- package/dist/src/local-mcp-server/usage-collector.js +5 -29
- package/dist/src/mcp/tool-schemas.js +2 -2
- package/dist/src/routes/analytics.js +10 -3
- package/dist/src/services/mcp-service.js +71 -11
- package/dist/src/services/usage-analytics-service.js +6 -3
- package/package.json +2 -1
|
@@ -66,8 +66,10 @@ const redact_secrets_1 = require("../core/utils/redact-secrets");
|
|
|
66
66
|
const pack_home_1 = require("../cli/utils/pack-home");
|
|
67
67
|
const local_registry_resolver_1 = require("../core/utils/local-registry-resolver");
|
|
68
68
|
const ai_mentor_1 = require("../core/ai-mentor");
|
|
69
|
+
const run_recovery_state_1 = require("../core/run-recovery-state");
|
|
69
70
|
const quality_evidence_1 = require("../core/quality-evidence");
|
|
70
71
|
const handoff_contracts_1 = require("../core/handoff-contracts");
|
|
72
|
+
const proposal_set_contract_1 = require("../core/proposal-set-contract");
|
|
71
73
|
const test_evidence_contract_1 = require("../core/test-evidence-contract");
|
|
72
74
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
73
75
|
const usage_collector_js_1 = require("./usage-collector.js");
|
|
@@ -530,6 +532,8 @@ class FraimLocalMCPServer {
|
|
|
530
532
|
this.machineInfo = null;
|
|
531
533
|
this.repoInfo = null;
|
|
532
534
|
this.engine = null;
|
|
535
|
+
this.usageUploadInFlight = null;
|
|
536
|
+
this.usageUploadRequested = false;
|
|
533
537
|
this.otlpServer = null;
|
|
534
538
|
this.isShutdown = false;
|
|
535
539
|
this.mentoringResponseCache = null;
|
|
@@ -567,6 +571,8 @@ class FraimLocalMCPServer {
|
|
|
567
571
|
if (this.isShutdown)
|
|
568
572
|
return;
|
|
569
573
|
this.isShutdown = true;
|
|
574
|
+
this.usageUploadRequested = false;
|
|
575
|
+
// Telemetry is best effort: process exit does not wait for or replay uploads.
|
|
570
576
|
this.usageCollector.shutdown();
|
|
571
577
|
if (this.otlpServer?.server) {
|
|
572
578
|
(0, otlp_metrics_receiver_js_1.stopOtlpReceiver)(this.otlpServer.server);
|
|
@@ -1195,58 +1201,179 @@ class FraimLocalMCPServer {
|
|
|
1195
1201
|
return null;
|
|
1196
1202
|
return (0, path_1.join)(homeDir, '.fraim', 'cache', 'registry', 'providers', filename);
|
|
1197
1203
|
}
|
|
1198
|
-
/**
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
const homeDir = this.getHomeDir();
|
|
1206
|
-
if (!homeDir)
|
|
1207
|
-
return null;
|
|
1208
|
-
return (0, path_1.join)(homeDir, '.fraim', 'run-state', `${jobId}.json`);
|
|
1204
|
+
/** Session/job-name key also admits starts that do not yet carry a jobId. */
|
|
1205
|
+
getRunStateDir() {
|
|
1206
|
+
return (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'run-state');
|
|
1207
|
+
}
|
|
1208
|
+
getRunStateMarkerPath(sessionId, jobName) {
|
|
1209
|
+
const key = (0, crypto_1.createHash)('sha256').update(JSON.stringify([sessionId, jobName])).digest('hex');
|
|
1210
|
+
return (0, path_1.join)(this.getRunStateDir(), `phase-${key}.json`);
|
|
1209
1211
|
}
|
|
1210
|
-
|
|
1211
|
-
readRunStateMarker(jobId) {
|
|
1212
|
+
readRunStateMarker(sessionId, jobName) {
|
|
1212
1213
|
try {
|
|
1213
|
-
const markerPath = this.getRunStateMarkerPath(
|
|
1214
|
+
const markerPath = this.getRunStateMarkerPath(sessionId, jobName);
|
|
1214
1215
|
if (!markerPath || !(0, fs_1.existsSync)(markerPath))
|
|
1215
1216
|
return undefined;
|
|
1216
1217
|
return JSON.parse((0, fs_1.readFileSync)(markerPath, 'utf8'));
|
|
1217
1218
|
}
|
|
1218
1219
|
catch (error) {
|
|
1219
|
-
this.log(
|
|
1220
|
+
this.log(`[phase-start] Could not read marker: ${error.message}`);
|
|
1220
1221
|
return undefined;
|
|
1221
1222
|
}
|
|
1222
1223
|
}
|
|
1223
|
-
|
|
1224
|
-
writeRunStateMarker(jobId, phaseId, timestamp) {
|
|
1224
|
+
writeRunStateMarker(sessionId, jobName, marker) {
|
|
1225
1225
|
try {
|
|
1226
|
-
const markerPath = this.getRunStateMarkerPath(
|
|
1226
|
+
const markerPath = this.getRunStateMarkerPath(sessionId, jobName);
|
|
1227
1227
|
if (!markerPath)
|
|
1228
1228
|
return;
|
|
1229
|
-
|
|
1230
|
-
|
|
1229
|
+
if (!marker.phaseInstances?.length) {
|
|
1230
|
+
if ((0, fs_1.existsSync)(markerPath))
|
|
1231
|
+
(0, fs_1.unlinkSync)(markerPath);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1231
1234
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(markerPath), { recursive: true });
|
|
1232
|
-
(0, fs_1.writeFileSync)(markerPath, JSON.stringify(
|
|
1235
|
+
(0, fs_1.writeFileSync)(markerPath, JSON.stringify(marker), 'utf8');
|
|
1233
1236
|
}
|
|
1234
1237
|
catch (error) {
|
|
1235
|
-
this.log(
|
|
1238
|
+
this.log(`[phase-start] Could not persist marker: ${error.message}`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/** Publish complete JSON before updating its discoverability pointers. */
|
|
1242
|
+
writeRecoveryJson(path, value) {
|
|
1243
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
|
|
1244
|
+
const temporary = `${path}.${(0, crypto_1.randomUUID)()}.tmp`;
|
|
1245
|
+
try {
|
|
1246
|
+
(0, fs_1.writeFileSync)(temporary, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
|
|
1247
|
+
(0, fs_1.renameSync)(temporary, path);
|
|
1248
|
+
}
|
|
1249
|
+
finally {
|
|
1250
|
+
if ((0, fs_1.existsSync)(temporary))
|
|
1251
|
+
(0, fs_1.unlinkSync)(temporary);
|
|
1236
1252
|
}
|
|
1237
1253
|
}
|
|
1238
|
-
/**
|
|
1239
|
-
|
|
1254
|
+
/**
|
|
1255
|
+
* Record every mentoring attempt, then advance only after accepted response
|
|
1256
|
+
* finalization. No request-supplied nextPhase can advance durable recovery.
|
|
1257
|
+
* The Hub run pointer bridges CLI and MCP session IDs, which are unrelated.
|
|
1258
|
+
*/
|
|
1259
|
+
recordRecoveryState(args, sessionId, acceptedNext) {
|
|
1260
|
+
if (!sessionId || sessionId === 'unknown'
|
|
1261
|
+
|| typeof args.jobName !== 'string' || !args.jobName
|
|
1262
|
+
|| typeof args.currentPhase !== 'string' || !args.currentPhase
|
|
1263
|
+
|| !['starting', 'complete', 'failure', 'incomplete'].includes(args.status))
|
|
1264
|
+
return;
|
|
1240
1265
|
try {
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
|
|
1266
|
+
const root = this.getRunStateDir();
|
|
1267
|
+
const jobId = typeof args.jobId === 'string' && args.jobId ? args.jobId : undefined;
|
|
1268
|
+
const stateFile = jobId ? `${(0, run_recovery_state_1.safeRunStateKey)(jobId)}.json`
|
|
1269
|
+
: `anonymous-${(0, run_recovery_state_1.safeRunStateKey)(JSON.stringify([sessionId, args.jobName]))}.json`;
|
|
1270
|
+
const statePath = (0, path_1.join)(root, stateFile);
|
|
1271
|
+
let previous;
|
|
1272
|
+
let priorPath = statePath;
|
|
1273
|
+
if (jobId && !(0, fs_1.existsSync)(statePath)) {
|
|
1274
|
+
// Bind the currently anonymous run without adopting an older run's history.
|
|
1275
|
+
const anonymousFile = `anonymous-${(0, run_recovery_state_1.safeRunStateKey)(JSON.stringify([sessionId, args.jobName]))}.json`;
|
|
1276
|
+
const sessionPath = (0, path_1.join)(root, 'sessions', `${(0, run_recovery_state_1.safeRunStateKey)(sessionId)}.json`);
|
|
1277
|
+
try {
|
|
1278
|
+
const pointer = JSON.parse((0, fs_1.readFileSync)(sessionPath, 'utf8'));
|
|
1279
|
+
if (!pointer.jobId && pointer.sessionId === sessionId && pointer.stateFile === anonymousFile) {
|
|
1280
|
+
priorPath = (0, path_1.join)(root, anonymousFile);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
catch { /* No valid anonymous pointer to promote. */ }
|
|
1244
1284
|
}
|
|
1285
|
+
if ((0, fs_1.existsSync)(priorPath)) {
|
|
1286
|
+
try {
|
|
1287
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(priorPath, 'utf8'));
|
|
1288
|
+
const identityMatches = parsed?.jobId === jobId
|
|
1289
|
+
|| (priorPath !== statePath && !parsed?.jobId && parsed?.sessionId === sessionId);
|
|
1290
|
+
if (identityMatches && parsed?.currentJobName === args.jobName
|
|
1291
|
+
&& typeof parsed.sessionId === 'string'
|
|
1292
|
+
&& ['active', 'waiting_review', 'terminal'].includes(parsed.status)
|
|
1293
|
+
&& (typeof parsed.currentPhase === 'string' || parsed.currentPhase === null)
|
|
1294
|
+
&& parsed.startedPhases && typeof parsed.startedPhases === 'object' && !Array.isArray(parsed.startedPhases)
|
|
1295
|
+
&& Array.isArray(parsed.completedPhases))
|
|
1296
|
+
previous = parsed;
|
|
1297
|
+
}
|
|
1298
|
+
catch (error) {
|
|
1299
|
+
this.log(`[phase-recovery] Rebuilding unreadable state: ${error.message}`);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
const timestamp = new Date().toISOString();
|
|
1303
|
+
const accepted = acceptedNext !== undefined;
|
|
1304
|
+
const currentPhase = accepted
|
|
1305
|
+
? (args.status === 'complete' ? acceptedNext.nextPhase : acceptedNext.nextPhase || args.currentPhase)
|
|
1306
|
+
: previous ? previous.currentPhase : args.currentPhase;
|
|
1307
|
+
const completedPhases = previous?.completedPhases || [];
|
|
1308
|
+
if (accepted && (args.status === 'complete' || args.status === 'failure')) {
|
|
1309
|
+
completedPhases.push({ phase: args.currentPhase, status: args.status, timestamp });
|
|
1310
|
+
}
|
|
1311
|
+
const parsedHandoff = [args.reviewHandoff, args.evidence?.reviewHandoff, args.findings?.reviewHandoff]
|
|
1312
|
+
.map((value) => {
|
|
1313
|
+
if (typeof value !== 'string')
|
|
1314
|
+
return value;
|
|
1315
|
+
try {
|
|
1316
|
+
return JSON.parse(value);
|
|
1317
|
+
}
|
|
1318
|
+
catch {
|
|
1319
|
+
return undefined;
|
|
1320
|
+
}
|
|
1321
|
+
})
|
|
1322
|
+
.find((value) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.reviewRequired === 'boolean');
|
|
1323
|
+
const status = !accepted && previous?.status !== 'active' && previous?.status
|
|
1324
|
+
? previous.status
|
|
1325
|
+
: currentPhase === 'address-feedback'
|
|
1326
|
+
|| (previous?.status === 'waiting_review' && currentPhase === previous.currentPhase && args.status !== 'failure')
|
|
1327
|
+
|| (accepted && args.status === 'complete' && parsedHandoff?.reviewRequired === true)
|
|
1328
|
+
? 'waiting_review' : currentPhase === null ? 'terminal' : 'active';
|
|
1329
|
+
const state = {
|
|
1330
|
+
jobId, sessionId, currentJobName: args.jobName, currentPhase,
|
|
1331
|
+
issueNumber: typeof args.issueNumber === 'string' ? args.issueNumber : previous?.issueNumber,
|
|
1332
|
+
status, lastStatus: args.status, lastAttemptPhase: args.currentPhase, updatedAt: timestamp,
|
|
1333
|
+
startedPhases: accepted && args.status === 'starting'
|
|
1334
|
+
? { ...previous?.startedPhases, [currentPhase || args.currentPhase]: timestamp }
|
|
1335
|
+
: previous?.startedPhases || {},
|
|
1336
|
+
completedPhases,
|
|
1337
|
+
};
|
|
1338
|
+
this.writeRecoveryJson(statePath, state);
|
|
1339
|
+
const pointer = { jobId, sessionId, stateFile };
|
|
1340
|
+
this.writeRecoveryJson((0, path_1.join)(root, 'sessions', `${(0, run_recovery_state_1.safeRunStateKey)(sessionId)}.json`), pointer);
|
|
1341
|
+
const hubRunId = process.env.FRAIM_HUB_RUN_ID;
|
|
1342
|
+
if (hubRunId)
|
|
1343
|
+
this.writeRecoveryJson((0, path_1.join)(root, 'runs', `${(0, run_recovery_state_1.safeRunStateKey)(hubRunId)}.json`), pointer);
|
|
1245
1344
|
}
|
|
1246
1345
|
catch (error) {
|
|
1247
|
-
this.log(
|
|
1346
|
+
this.log(`[phase-recovery] Could not persist recovery state: ${error.message}`);
|
|
1248
1347
|
}
|
|
1249
1348
|
}
|
|
1349
|
+
/** Called only after the response has passed every rejecting contract. */
|
|
1350
|
+
acceptPhaseTracking(args, sessionId, nextPhase) {
|
|
1351
|
+
this.recordRecoveryState(args, sessionId, { nextPhase });
|
|
1352
|
+
if (!sessionId || sessionId === 'unknown' || !args.jobName || !args.currentPhase) {
|
|
1353
|
+
return args.status === 'complete' || args.status === 'failure'
|
|
1354
|
+
? `Phase-start notice: phase ${args.currentPhase || '(unknown)'} cannot be matched to a run because its session or job identity is missing. Completion remains accepted.\n\n` : '';
|
|
1355
|
+
}
|
|
1356
|
+
const context = {
|
|
1357
|
+
sessionId, jobName: args.jobName, phaseId: args.currentPhase, jobId: args.jobId,
|
|
1358
|
+
resumeId: process.env.FRAIM_PHASE_RESUME_ID || undefined,
|
|
1359
|
+
};
|
|
1360
|
+
const timestamp = new Date().toISOString();
|
|
1361
|
+
const marker = this.readRunStateMarker(sessionId, args.jobName);
|
|
1362
|
+
if (args.status === 'starting') {
|
|
1363
|
+
this.writeRunStateMarker(sessionId, args.jobName, (0, handoff_contracts_1.recordPhaseStart)(marker, { ...context, timestamp }));
|
|
1364
|
+
return '';
|
|
1365
|
+
}
|
|
1366
|
+
if (args.status !== 'complete' && args.status !== 'failure')
|
|
1367
|
+
return '';
|
|
1368
|
+
const inspection = (0, handoff_contracts_1.inspectPhaseCompletion)(marker, context);
|
|
1369
|
+
const updated = (0, handoff_contracts_1.commitPhaseCompletion)(marker, inspection, { ...context, timestamp });
|
|
1370
|
+
if (args.status === 'complete' && nextPhase === null) {
|
|
1371
|
+
updated.phaseInstances = updated.phaseInstances?.filter((instance) => instance.jobId && instance.jobId !== args.jobId);
|
|
1372
|
+
}
|
|
1373
|
+
this.writeRunStateMarker(sessionId, args.jobName, updated);
|
|
1374
|
+
return (inspection.missingStart ? (0, handoff_contracts_1.buildPhaseStartWarning)(args.currentPhase) : '')
|
|
1375
|
+
+ (!args.jobId ? `Run-tracking notice: phase ${args.currentPhase} has no jobId; matching used the session, job name and phase.\n\n` : '');
|
|
1376
|
+
}
|
|
1250
1377
|
readCachedTemplateFile(filename) {
|
|
1251
1378
|
try {
|
|
1252
1379
|
const cachePath = this.getProviderCachePath(filename);
|
|
@@ -2428,41 +2555,40 @@ class FraimLocalMCPServer {
|
|
|
2428
2555
|
if (request.method === 'tools/call' && typeof toolName === 'string') {
|
|
2429
2556
|
// 🔍 SMART DISPATCHER: Intercept mentoring and job/workflow tools for local overrides
|
|
2430
2557
|
if (toolName === 'seekMentoring') {
|
|
2558
|
+
this.recordRecoveryState(args, requestSessionId);
|
|
2559
|
+
// #1766: report an unrenderable supplied handoff before any mentoring
|
|
2560
|
+
// success response, including when phase-policy enforcement is disabled.
|
|
2561
|
+
// Match the host's direct/evidence/findings precedence and JSON support.
|
|
2562
|
+
const suppliedHandoffs = [args.reviewHandoff, args.evidence?.reviewHandoff, args.findings?.reviewHandoff]
|
|
2563
|
+
.filter((value) => value !== undefined && value !== null)
|
|
2564
|
+
.map((value) => {
|
|
2565
|
+
if (typeof value !== 'string')
|
|
2566
|
+
return value;
|
|
2567
|
+
try {
|
|
2568
|
+
return JSON.parse(value);
|
|
2569
|
+
}
|
|
2570
|
+
catch {
|
|
2571
|
+
return value;
|
|
2572
|
+
}
|
|
2573
|
+
});
|
|
2574
|
+
const suppliedHandoff = suppliedHandoffs.find((value) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.reviewRequired === 'boolean') ?? suppliedHandoffs[0];
|
|
2575
|
+
if (suppliedHandoffs.length > 0) {
|
|
2576
|
+
const normalizationError = (0, handoff_contracts_1.reviewHandoffNormalizationError)(suppliedHandoff);
|
|
2577
|
+
if (normalizationError) {
|
|
2578
|
+
return {
|
|
2579
|
+
jsonrpc: '2.0',
|
|
2580
|
+
id: request.id,
|
|
2581
|
+
result: {
|
|
2582
|
+
isError: true,
|
|
2583
|
+
content: [{ type: 'text', text: `Review handoff was not applied: ${normalizationError}. This response does not confirm delivery.` }],
|
|
2584
|
+
},
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2431
2588
|
try {
|
|
2432
2589
|
const mentor = this.getMentor(requestSessionId);
|
|
2433
2590
|
const tutoringResponse = await mentor.handleMentoringRequest(args);
|
|
2434
2591
|
this.log(`✅ Local seekMentoring succeeded for ${args.jobName}:${args.currentPhase}`);
|
|
2435
|
-
// Store response data for usage tracking (Bug fix: capture nextPhase and jobId)
|
|
2436
|
-
if (!this.mentoringResponseCache) {
|
|
2437
|
-
this.mentoringResponseCache = new Map();
|
|
2438
|
-
}
|
|
2439
|
-
this.mentoringResponseCache.set(request.id, {
|
|
2440
|
-
nextPhase: tutoringResponse.nextPhase,
|
|
2441
|
-
jobId: args.jobId || requestSessionId // Use jobId from args, fallback to sessionId
|
|
2442
|
-
});
|
|
2443
|
-
// Phase-start marker (Issue #1590 — warn-only).
|
|
2444
|
-
//
|
|
2445
|
-
// Detects a resumed address-feedback turn that never called
|
|
2446
|
-
// seekMentoring(status: "starting") for this run: the mentoring
|
|
2447
|
-
// response cache above is in-memory, keyed by request.id, and does
|
|
2448
|
-
// not survive the process restart a Hub resume performs, so a
|
|
2449
|
-
// durable per-jobId marker on disk is the only signal that
|
|
2450
|
-
// survives it. Scoped to address-feedback only (PHASE_START_WARN_PHASES);
|
|
2451
|
-
// never rejects — a lost marker (disk cleanup, first-ever run
|
|
2452
|
-
// before this shipped) must not strand a legitimate resume.
|
|
2453
|
-
let phaseStartWarning = '';
|
|
2454
|
-
if (args.jobId && handoff_contracts_1.PHASE_START_WARN_PHASES.includes(args.currentPhase)) {
|
|
2455
|
-
if (args.status === 'starting') {
|
|
2456
|
-
this.writeRunStateMarker(args.jobId, args.currentPhase, new Date().toISOString());
|
|
2457
|
-
}
|
|
2458
|
-
else if (args.status === 'complete' || args.status === 'failure') {
|
|
2459
|
-
const marker = this.readRunStateMarker(args.jobId);
|
|
2460
|
-
if (!(0, handoff_contracts_1.hasPhaseStarted)(marker, args.currentPhase)) {
|
|
2461
|
-
this.log(`⚠️ [phase-start] ${args.currentPhase} entered without a starting call this run: jobId=${args.jobId}`);
|
|
2462
|
-
phaseStartWarning = (0, handoff_contracts_1.buildPhaseStartWarning)(args.currentPhase);
|
|
2463
|
-
}
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
2592
|
// Quality enforcement (Issue #251).
|
|
2467
2593
|
//
|
|
2468
2594
|
// The local proxy owns seekMentoring for personalized-job support.
|
|
@@ -2551,6 +2677,16 @@ class FraimLocalMCPServer {
|
|
|
2551
2677
|
repo: handoffRepoState,
|
|
2552
2678
|
});
|
|
2553
2679
|
handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
|
|
2680
|
+
// Issue #1857: enforced on its own terms rather than folded into
|
|
2681
|
+
// handoffErrors. `missingField` below reads handoffErrors[0], and the
|
|
2682
|
+
// reviewHandoff escape hatch keys off that, so a reviewHandoff error in
|
|
2683
|
+
// slot 0 would silently carry a bad proposalSet through with it. These
|
|
2684
|
+
// are independent contracts and stay independent.
|
|
2685
|
+
const proposalSetErrors = (0, proposal_set_contract_1.validateProposalSet)(args.evidence?.proposalSet);
|
|
2686
|
+
if (proposalSetErrors) {
|
|
2687
|
+
this.log(`⚠️ Proposal set contract rejected seekMentoring for ${args.jobName}:${args.currentPhase}: ${proposalSetErrors.join('; ')}`);
|
|
2688
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, 'proposalSet', proposalSetErrors));
|
|
2689
|
+
}
|
|
2554
2690
|
if (handoffErrors.length > 0) {
|
|
2555
2691
|
const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
|
|
2556
2692
|
: handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
|
|
@@ -2616,7 +2752,20 @@ class FraimLocalMCPServer {
|
|
|
2616
2752
|
const rejection = (0, test_evidence_contract_1.buildTestEvidenceRejectionMessage)(args.currentPhase, testEvidenceErrors);
|
|
2617
2753
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
|
|
2618
2754
|
}
|
|
2619
|
-
|
|
2755
|
+
const acceptedResponse = await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
2756
|
+
if (acceptedResponse.error || acceptedResponse.result?.isError)
|
|
2757
|
+
return acceptedResponse;
|
|
2758
|
+
const phaseStartWarning = this.acceptPhaseTracking(args, requestSessionId, tutoringResponse.nextPhase);
|
|
2759
|
+
if (phaseStartWarning && acceptedResponse.result?.content?.[0]) {
|
|
2760
|
+
acceptedResponse.result.content[0].text = phaseStartWarning + acceptedResponse.result.content[0].text;
|
|
2761
|
+
}
|
|
2762
|
+
if (!this.mentoringResponseCache)
|
|
2763
|
+
this.mentoringResponseCache = new Map();
|
|
2764
|
+
this.mentoringResponseCache.set(request.id, {
|
|
2765
|
+
nextPhase: tutoringResponse.nextPhase,
|
|
2766
|
+
jobId: args.jobId,
|
|
2767
|
+
});
|
|
2768
|
+
return acceptedResponse;
|
|
2620
2769
|
}
|
|
2621
2770
|
catch (error) {
|
|
2622
2771
|
this.log(`⚠️ Local seekMentoring failed: ${error.message}. Falling back to remote.`);
|
|
@@ -2735,6 +2884,19 @@ class FraimLocalMCPServer {
|
|
|
2735
2884
|
}
|
|
2736
2885
|
const response = await this._doProxyToRemote(injectedRequest, requestId);
|
|
2737
2886
|
const processedResponse = await this.finalizeToolResponse(injectedRequest, response, requestSessionId, requestId);
|
|
2887
|
+
// Reconcile remote fallback against the same persisted local instance.
|
|
2888
|
+
// Only the remote's explicit accepted result can consume a start.
|
|
2889
|
+
if (toolName === 'seekMentoring' && !processedResponse.error && !processedResponse.result?.isError
|
|
2890
|
+
&& response.result?.structuredContent?.phaseAccepted === true) {
|
|
2891
|
+
const metadata = response.result.structuredContent;
|
|
2892
|
+
const textBlock = processedResponse.result?.content?.find((item) => item.type === 'text');
|
|
2893
|
+
if (textBlock) {
|
|
2894
|
+
const remoteWarning = metadata.phaseStartWarning;
|
|
2895
|
+
if (remoteWarning)
|
|
2896
|
+
textBlock.text = textBlock.text.replace(remoteWarning, '');
|
|
2897
|
+
textBlock.text = this.acceptPhaseTracking(args, requestSessionId, metadata.nextPhase) + textBlock.text;
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2738
2900
|
this.log(`📤 ${injectedRequest.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
|
|
2739
2901
|
return processedResponse;
|
|
2740
2902
|
}
|
|
@@ -2829,9 +2991,7 @@ class FraimLocalMCPServer {
|
|
|
2829
2991
|
process.stdin.setEncoding('utf8');
|
|
2830
2992
|
// Set up periodic usage data upload
|
|
2831
2993
|
const uploadInterval = setInterval(() => {
|
|
2832
|
-
this.uploadUsageData()
|
|
2833
|
-
this.log(`⚠️ Failed to upload usage data: ${error.message}`);
|
|
2834
|
-
});
|
|
2994
|
+
void this.uploadUsageData();
|
|
2835
2995
|
}, 60000); // Upload every minute
|
|
2836
2996
|
uploadInterval.unref?.();
|
|
2837
2997
|
// Clean up interval on shutdown
|
|
@@ -2923,7 +3083,8 @@ class FraimLocalMCPServer {
|
|
|
2923
3083
|
let args = request.params?.arguments || {};
|
|
2924
3084
|
const requestSessionId = this.extractSessionIdFromRequest(request);
|
|
2925
3085
|
if (toolName && requestSessionId) {
|
|
2926
|
-
const success = !response.error;
|
|
3086
|
+
const success = !response.error && !response.result?.isError;
|
|
3087
|
+
let acceptedPhaseResponse = success && response.result?.structuredContent?.phaseAccepted === true;
|
|
2927
3088
|
this.log(`📊 Collecting usage: ${toolName} (session: ${requestSessionId}, success: ${success})`);
|
|
2928
3089
|
if (!this.repoInfo) {
|
|
2929
3090
|
this.detectRepoInfo();
|
|
@@ -2933,6 +3094,7 @@ class FraimLocalMCPServer {
|
|
|
2933
3094
|
const requestId = request.id;
|
|
2934
3095
|
const cachedResponse = this.mentoringResponseCache.get(requestId);
|
|
2935
3096
|
if (cachedResponse) {
|
|
3097
|
+
acceptedPhaseResponse = success;
|
|
2936
3098
|
args = {
|
|
2937
3099
|
...args,
|
|
2938
3100
|
nextPhase: cachedResponse.nextPhase,
|
|
@@ -2952,38 +3114,19 @@ class FraimLocalMCPServer {
|
|
|
2952
3114
|
this.log(`📊 Enriched get_fraim_job args with jobId=${cached.jobId}, action=start`);
|
|
2953
3115
|
}
|
|
2954
3116
|
}
|
|
2955
|
-
//
|
|
3117
|
+
// Keep enrichment request-local until it settles. Neither another call
|
|
3118
|
+
// nor the uploader may observe an event that is still being constructed.
|
|
3119
|
+
const enrichment = toolName === 'seekMentoring' ? await this.captureTokenUsage() : {};
|
|
3120
|
+
if (this.isShutdown)
|
|
3121
|
+
return;
|
|
3122
|
+
// Capture the current queue size before synchronous publication.
|
|
2956
3123
|
const beforeCount = this.usageCollector.getEventCount();
|
|
2957
3124
|
try {
|
|
2958
|
-
this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success);
|
|
3125
|
+
this.usageCollector.collectMCPCall(toolName, args, requestSessionId, success, enrichment);
|
|
2959
3126
|
// Check if the event was actually added to the queue
|
|
2960
3127
|
const afterCount = this.usageCollector.getEventCount();
|
|
2961
3128
|
if (afterCount > beforeCount) {
|
|
2962
3129
|
this.log(`📊 ✅ Event queued successfully (queue: ${afterCount})`);
|
|
2963
|
-
// Issue #330: pluggable token capture by agent.
|
|
2964
|
-
if (toolName === 'seekMentoring') {
|
|
2965
|
-
try {
|
|
2966
|
-
const agentName = this.agentInfo?.name;
|
|
2967
|
-
const agentModel = this.agentInfo?.model;
|
|
2968
|
-
const adapter = (0, token_adapter_registry_js_1.resolveTokenAdapter)(agentName);
|
|
2969
|
-
const result = await adapter.capture({
|
|
2970
|
-
agent: agentName,
|
|
2971
|
-
model: agentModel,
|
|
2972
|
-
log: (msg) => this.log(`📊 ${msg}`),
|
|
2973
|
-
});
|
|
2974
|
-
if (result.snapshot) {
|
|
2975
|
-
this.usageCollector.attachTokenSnapshot(result.snapshot);
|
|
2976
|
-
this.log(`📊 🔢 Token snapshot attached via ${adapter.id} adapter (input=${result.snapshot.inputTokens}, output=${result.snapshot.outputTokens})`);
|
|
2977
|
-
}
|
|
2978
|
-
else if (result.reason) {
|
|
2979
|
-
this.usageCollector.attachCaptureUnavailable(result.reason);
|
|
2980
|
-
this.log(`📊 ℹ️ Token capture unavailable (${result.reason}) for adapter ${adapter.id}`);
|
|
2981
|
-
}
|
|
2982
|
-
}
|
|
2983
|
-
catch (err) {
|
|
2984
|
-
this.log(`📊 ⚠️ Token snapshot capture failed (non-blocking): ${err.message}`);
|
|
2985
|
-
}
|
|
2986
|
-
}
|
|
2987
3130
|
}
|
|
2988
3131
|
else {
|
|
2989
3132
|
this.log(`📊 ⚠️ Event not queued - tool may not be tracked: ${toolName}`);
|
|
@@ -2993,7 +3136,7 @@ class FraimLocalMCPServer {
|
|
|
2993
3136
|
}
|
|
2994
3137
|
}
|
|
2995
3138
|
// Emit job-complete event when seekMentoring reaches the final phase
|
|
2996
|
-
if (toolName === 'seekMentoring' && args.nextPhase === null && args.jobName && args.jobId) {
|
|
3139
|
+
if (toolName === 'seekMentoring' && acceptedPhaseResponse && args.status === 'complete' && args.nextPhase === null && args.jobName && args.jobId) {
|
|
2997
3140
|
try {
|
|
2998
3141
|
this.usageCollector.collectJobComplete(args.jobName, args.jobId, requestSessionId);
|
|
2999
3142
|
this.log(`📊 ✅ Job complete event emitted: ${args.jobName} (jobId: ${args.jobId})`);
|
|
@@ -3001,10 +3144,11 @@ class FraimLocalMCPServer {
|
|
|
3001
3144
|
catch (err) {
|
|
3002
3145
|
this.log(`📊 ⚠️ Job complete event failed (non-blocking): ${err.message}`);
|
|
3003
3146
|
}
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
//
|
|
3007
|
-
|
|
3147
|
+
}
|
|
3148
|
+
if (toolName === 'seekMentoring' && afterCount > beforeCount) {
|
|
3149
|
+
// Start HTTP now, after the terminal pair is collected, without making
|
|
3150
|
+
// the local mentoring response depend on transport latency or success.
|
|
3151
|
+
void this.uploadUsageData();
|
|
3008
3152
|
}
|
|
3009
3153
|
}
|
|
3010
3154
|
catch (error) {
|
|
@@ -3015,6 +3159,30 @@ class FraimLocalMCPServer {
|
|
|
3015
3159
|
this.log(`⚠️ Skipping usage collection: toolName=${toolName}, sessionId=${requestSessionId}`);
|
|
3016
3160
|
}
|
|
3017
3161
|
}
|
|
3162
|
+
/** Capture only this request's enrichment before publishing its usage event. */
|
|
3163
|
+
async captureTokenUsage() {
|
|
3164
|
+
try {
|
|
3165
|
+
const agentName = this.agentInfo?.name;
|
|
3166
|
+
const agentModel = this.agentInfo?.model;
|
|
3167
|
+
const adapter = (0, token_adapter_registry_js_1.resolveTokenAdapter)(agentName);
|
|
3168
|
+
const result = await adapter.capture({
|
|
3169
|
+
agent: agentName,
|
|
3170
|
+
model: agentModel,
|
|
3171
|
+
log: (msg) => this.log(`📊 ${msg}`),
|
|
3172
|
+
});
|
|
3173
|
+
if (result.snapshot) {
|
|
3174
|
+
this.log(`Token snapshot captured via ${adapter.id} adapter (input=${result.snapshot.inputTokens}, output=${result.snapshot.outputTokens})`);
|
|
3175
|
+
return { tokenSnapshot: result.snapshot };
|
|
3176
|
+
}
|
|
3177
|
+
const reason = result.reason || 'token_capture_unavailable';
|
|
3178
|
+
this.log(`Token capture unavailable (${reason}) for adapter ${adapter.id}`);
|
|
3179
|
+
return { tokenCaptureUnavailableReason: reason };
|
|
3180
|
+
}
|
|
3181
|
+
catch {
|
|
3182
|
+
this.log('Token capture failed; recording unavailable');
|
|
3183
|
+
return { tokenCaptureUnavailableReason: 'token_capture_error' };
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3018
3186
|
/**
|
|
3019
3187
|
* Emit a quality score to the remote FRAIM server (Issue #251).
|
|
3020
3188
|
*
|
|
@@ -3045,24 +3213,39 @@ class FraimLocalMCPServer {
|
|
|
3045
3213
|
/**
|
|
3046
3214
|
* Flush collected usage data to the remote server
|
|
3047
3215
|
*/
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
this.
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3216
|
+
uploadUsageData() {
|
|
3217
|
+
if (this.isShutdown)
|
|
3218
|
+
return Promise.resolve();
|
|
3219
|
+
this.usageUploadRequested = true;
|
|
3220
|
+
if (this.usageUploadInFlight)
|
|
3221
|
+
return this.usageUploadInFlight;
|
|
3222
|
+
const upload = async () => {
|
|
3223
|
+
while (this.usageUploadRequested && !this.isShutdown) {
|
|
3224
|
+
this.usageUploadRequested = false;
|
|
3225
|
+
const eventCount = this.usageCollector.getEventCount();
|
|
3226
|
+
if (eventCount === 0)
|
|
3227
|
+
return;
|
|
3228
|
+
const startedAt = Date.now();
|
|
3229
|
+
try {
|
|
3230
|
+
await this.usageCollector.flush(this.remoteUrl, this.apiKey);
|
|
3231
|
+
this.log(`Usage upload succeeded: ${eventCount} events, ${Date.now() - startedAt}ms`);
|
|
3232
|
+
}
|
|
3233
|
+
catch {
|
|
3234
|
+
// The collector alone restores the failed snapshot. Even if another
|
|
3235
|
+
// trigger arrived in flight, wait for a later trigger after failure.
|
|
3236
|
+
this.usageUploadRequested = false;
|
|
3237
|
+
this.log(`Usage upload failed; buffered for a later trigger (${Date.now() - startedAt}ms)`);
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3064
3240
|
}
|
|
3065
|
-
}
|
|
3241
|
+
};
|
|
3242
|
+
this.usageUploadInFlight = upload().finally(() => {
|
|
3243
|
+
this.usageUploadInFlight = null;
|
|
3244
|
+
// A trigger can run between the loop settling and this promise cleanup.
|
|
3245
|
+
if (this.usageUploadRequested && !this.isShutdown)
|
|
3246
|
+
return this.uploadUsageData();
|
|
3247
|
+
});
|
|
3248
|
+
return this.usageUploadInFlight;
|
|
3066
3249
|
}
|
|
3067
3250
|
}
|
|
3068
3251
|
exports.FraimLocalMCPServer = FraimLocalMCPServer;
|
|
@@ -57,7 +57,7 @@ class UsageCollector {
|
|
|
57
57
|
/**
|
|
58
58
|
* Collect MCP tool call event
|
|
59
59
|
*/
|
|
60
|
-
collectMCPCall(toolName, args, sessionId, success = true) {
|
|
60
|
+
collectMCPCall(toolName, args, sessionId, success = true, enrichment = {}) {
|
|
61
61
|
const parsed = this.parseMCPCall(toolName, args);
|
|
62
62
|
if (!parsed) {
|
|
63
63
|
const errorMsg = `[UsageCollector] 🚫 Tool not tracked: ${toolName}`;
|
|
@@ -83,6 +83,8 @@ class UsageCollector {
|
|
|
83
83
|
repoIdentifier: this.repoIdentifier || undefined,
|
|
84
84
|
agentName: this.agentName || undefined,
|
|
85
85
|
agentModel: this.agentModel || undefined,
|
|
86
|
+
tokenSnapshot: enrichment.tokenSnapshot,
|
|
87
|
+
tokenCaptureUnavailableReason: enrichment.tokenCaptureUnavailableReason,
|
|
86
88
|
jobId: args.jobId || undefined, // Bug fix: capture jobId from enriched args
|
|
87
89
|
args: Object.keys(analyticsArgs).length > 0 ? analyticsArgs : undefined
|
|
88
90
|
};
|
|
@@ -212,25 +214,6 @@ class UsageCollector {
|
|
|
212
214
|
this.events = [];
|
|
213
215
|
return eventsToUpload;
|
|
214
216
|
}
|
|
215
|
-
/**
|
|
216
|
-
* Attach a token snapshot to the most recently queued event.
|
|
217
|
-
* Used to associate Prometheus-scraped token data with seekMentoring events.
|
|
218
|
-
*/
|
|
219
|
-
attachTokenSnapshot(snapshot) {
|
|
220
|
-
if (this.events.length > 0) {
|
|
221
|
-
this.events[this.events.length - 1].tokenSnapshot = snapshot;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Issue #330: when a token capture adapter returns null, record the
|
|
226
|
-
* typed reason on the most recently queued event so the dashboard
|
|
227
|
-
* can explain coverage gaps to the user.
|
|
228
|
-
*/
|
|
229
|
-
attachCaptureUnavailable(reason) {
|
|
230
|
-
if (this.events.length > 0) {
|
|
231
|
-
this.events[this.events.length - 1].tokenCaptureUnavailableReason = reason;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
217
|
/**
|
|
235
218
|
* Get current event count
|
|
236
219
|
*/
|
|
@@ -269,17 +252,10 @@ class UsageCollector {
|
|
|
269
252
|
// Success - events are already cleared from the queue
|
|
270
253
|
}
|
|
271
254
|
catch (error) {
|
|
272
|
-
const status = error.response?.status;
|
|
273
|
-
const
|
|
274
|
-
const errorMsg = `[UsageCollector] ❌ Failed to flush usage events (HTTP ${status}): ${message}`;
|
|
255
|
+
const status = typeof error.response?.status === 'number' ? error.response.status : 'unavailable';
|
|
256
|
+
const errorMsg = `[UsageCollector] Failed to flush usage events (HTTP ${status}); batch retained for retry`;
|
|
275
257
|
console.error(errorMsg);
|
|
276
258
|
process.stderr.write(errorMsg + '\n');
|
|
277
|
-
// Log additional debug info
|
|
278
|
-
if (error.response?.data) {
|
|
279
|
-
const debugMsg = `[UsageCollector] 🔍 Response data: ${JSON.stringify(error.response.data)}`;
|
|
280
|
-
console.error(debugMsg);
|
|
281
|
-
process.stderr.write(debugMsg + '\n');
|
|
282
|
-
}
|
|
283
259
|
// Put events back at the beginning of the queue for next try
|
|
284
260
|
this.events = [...events, ...this.events];
|
|
285
261
|
// Re-throw the error so the main server can log it too
|
|
@@ -335,7 +335,7 @@ Do not use this tool for other repositories or external project issue trackers.`
|
|
|
335
335
|
},
|
|
336
336
|
jobId: {
|
|
337
337
|
type: 'string',
|
|
338
|
-
description: 'Job ID returned by get_fraim_job.
|
|
338
|
+
description: 'Job ID returned by get_fraim_job. Supply when available; phase starts without it match by session, job name and phase.'
|
|
339
339
|
},
|
|
340
340
|
issueNumber: {
|
|
341
341
|
type: 'string',
|
|
@@ -372,7 +372,7 @@ Do not use this tool for other repositories or external project issue trackers.`
|
|
|
372
372
|
description: 'Absolute path to the directory you are actually working in right now, when it differs from this session\'s default project directory — most commonly an issue worktree created by prep-issue.sh. Pass this on submit-phase calls carrying evidence.reviewHandoff so the review-action-completeness check reads the branch of your real worktree instead of a directory this long-lived session cached earlier. Optional; omit when working in the default project directory.'
|
|
373
373
|
}
|
|
374
374
|
},
|
|
375
|
-
required: requiredWithSession(['jobName', '
|
|
375
|
+
required: requiredWithSession(['jobName', 'issueNumber', 'currentPhase', 'status'])
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
];
|