fraim 2.0.227 → 2.0.229
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.
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Handoff Contract Validation (Issue #916)
|
|
4
|
+
*
|
|
5
|
+
* Pure helpers for validating the three evidence fields that FRAIM jobs must
|
|
6
|
+
* emit on key seekMentoring calls so the Hub can render its review surfaces:
|
|
7
|
+
*
|
|
8
|
+
* - evidence.reviewHandoff (submit phase, status === "awaiting_mentor")
|
|
9
|
+
* - evidence.nextJobRecommendations (retrospective phase completion)
|
|
10
|
+
* - evidence.delegationLedger (create-delegation-graph phase)
|
|
11
|
+
*
|
|
12
|
+
* Lives in src/core so both the local MCP proxy and evals can import the same
|
|
13
|
+
* contract without cross-layer dependencies — same pattern as quality-evidence.ts.
|
|
14
|
+
*
|
|
15
|
+
* Primary call site: src/local-mcp-server/stdio-server.ts seekMentoring handler,
|
|
16
|
+
* after the existing quality enforcement block.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.isSubmitPhase = isSubmitPhase;
|
|
20
|
+
exports.isRetrospectivePhase = isRetrospectivePhase;
|
|
21
|
+
exports.isDelegationGraphPhase = isDelegationGraphPhase;
|
|
22
|
+
exports.validateReviewHandoff = validateReviewHandoff;
|
|
23
|
+
exports.validateNextJobRecommendations = validateNextJobRecommendations;
|
|
24
|
+
exports.validateDelegationLedger = validateDelegationLedger;
|
|
25
|
+
exports.validateHandoffContracts = validateHandoffContracts;
|
|
26
|
+
exports.buildHandoffRejectionMessage = buildHandoffRejectionMessage;
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Phase detection
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* Returns true when a seekMentoring call is a submit-phase handoff requiring
|
|
32
|
+
* a reviewHandoff evidence field. The canonical signal is status === "awaiting_mentor"
|
|
33
|
+
* — set only by submit-phase jobs requesting a review gate — rather than a
|
|
34
|
+
* hardcoded phase name list, which is fragile for personalized jobs.
|
|
35
|
+
*/
|
|
36
|
+
function isSubmitPhase(currentPhase, status) {
|
|
37
|
+
return status === 'awaiting_mentor';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Returns true when a seekMentoring call is a retrospective completion.
|
|
41
|
+
* Retrospective jobs emit nextJobRecommendations at this boundary.
|
|
42
|
+
*/
|
|
43
|
+
function isRetrospectivePhase(currentPhase, status) {
|
|
44
|
+
return currentPhase === 'retrospective' && status === 'complete';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Returns true when a seekMentoring call is the delegation graph phase.
|
|
48
|
+
* This matches the existing Hub test fixture for the delegation board.
|
|
49
|
+
*/
|
|
50
|
+
function isDelegationGraphPhase(currentPhase) {
|
|
51
|
+
return currentPhase === 'create-delegation-graph';
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Individual validators
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/**
|
|
57
|
+
* Validates evidence.reviewHandoff. Returns null if valid, or an array of
|
|
58
|
+
* human-readable error strings describing what is wrong.
|
|
59
|
+
*
|
|
60
|
+
* Required minimum shape:
|
|
61
|
+
* {
|
|
62
|
+
* reviewRequired: boolean,
|
|
63
|
+
* reviewTarget: object | null,
|
|
64
|
+
* artifacts: array,
|
|
65
|
+
* }
|
|
66
|
+
*/
|
|
67
|
+
function validateReviewHandoff(value) {
|
|
68
|
+
if (value === undefined || value === null) {
|
|
69
|
+
return ['evidence.reviewHandoff is missing'];
|
|
70
|
+
}
|
|
71
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
72
|
+
return ['evidence.reviewHandoff must be an object'];
|
|
73
|
+
}
|
|
74
|
+
const obj = value;
|
|
75
|
+
const errors = [];
|
|
76
|
+
if (typeof obj.reviewRequired !== 'boolean') {
|
|
77
|
+
errors.push(`evidence.reviewHandoff.reviewRequired must be a boolean (got ${obj.reviewRequired === undefined ? 'missing' : typeof obj.reviewRequired})`);
|
|
78
|
+
}
|
|
79
|
+
if (obj.reviewRequired === true) {
|
|
80
|
+
if (!Array.isArray(obj.reviewActions) || obj.reviewActions.length === 0) {
|
|
81
|
+
errors.push('evidence.reviewHandoff.reviewActions must be a non-empty array when reviewRequired is true');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const actions = obj.reviewActions;
|
|
85
|
+
const hasApprove = actions.some((a) => a.kind === 'approve');
|
|
86
|
+
const hasRequestChanges = actions.some((a) => a.kind === 'request_changes');
|
|
87
|
+
if (!hasApprove) {
|
|
88
|
+
errors.push('evidence.reviewHandoff.reviewActions must include an entry with kind: "approve"');
|
|
89
|
+
}
|
|
90
|
+
if (!hasRequestChanges) {
|
|
91
|
+
errors.push('evidence.reviewHandoff.reviewActions must include an entry with kind: "request_changes"');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return errors.length > 0 ? errors : null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Validates evidence.nextJobRecommendations. Returns null if valid, or an
|
|
99
|
+
* array of error strings.
|
|
100
|
+
*
|
|
101
|
+
* Required minimum shape: an array (may be empty) of entries, each with:
|
|
102
|
+
* { jobId: string, label: string, reason?: string, contextSummary?: string }
|
|
103
|
+
*/
|
|
104
|
+
function validateNextJobRecommendations(value) {
|
|
105
|
+
if (value === undefined || value === null) {
|
|
106
|
+
return ['evidence.nextJobRecommendations is missing'];
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(value)) {
|
|
109
|
+
return ['evidence.nextJobRecommendations must be an array'];
|
|
110
|
+
}
|
|
111
|
+
const errors = [];
|
|
112
|
+
value.forEach((entry, i) => {
|
|
113
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
114
|
+
errors.push(`evidence.nextJobRecommendations[${i}] must be an object`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const rec = entry;
|
|
118
|
+
if (typeof rec.jobId !== 'string' || !rec.jobId.trim()) {
|
|
119
|
+
errors.push(`evidence.nextJobRecommendations[${i}].jobId must be a non-empty string`);
|
|
120
|
+
}
|
|
121
|
+
if (typeof rec.label !== 'string' || !rec.label.trim()) {
|
|
122
|
+
errors.push(`evidence.nextJobRecommendations[${i}].label must be a non-empty string`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return errors.length > 0 ? errors : null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Validates evidence.delegationLedger. Returns null if valid, or an array
|
|
129
|
+
* of error strings.
|
|
130
|
+
*
|
|
131
|
+
* Required minimum shape:
|
|
132
|
+
* {
|
|
133
|
+
* delegationRequired: boolean,
|
|
134
|
+
* tasks: array,
|
|
135
|
+
* }
|
|
136
|
+
*/
|
|
137
|
+
function validateDelegationLedger(value) {
|
|
138
|
+
if (value === undefined || value === null) {
|
|
139
|
+
return ['evidence.delegationLedger is missing'];
|
|
140
|
+
}
|
|
141
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
142
|
+
return ['evidence.delegationLedger must be an object'];
|
|
143
|
+
}
|
|
144
|
+
const obj = value;
|
|
145
|
+
const errors = [];
|
|
146
|
+
if (typeof obj.delegationRequired !== 'boolean') {
|
|
147
|
+
errors.push(`evidence.delegationLedger.delegationRequired must be a boolean (got ${obj.delegationRequired === undefined ? 'missing' : typeof obj.delegationRequired})`);
|
|
148
|
+
}
|
|
149
|
+
if (!('tasks' in obj)) {
|
|
150
|
+
errors.push('evidence.delegationLedger.tasks is missing');
|
|
151
|
+
}
|
|
152
|
+
else if (!Array.isArray(obj.tasks)) {
|
|
153
|
+
errors.push('evidence.delegationLedger.tasks must be an array');
|
|
154
|
+
}
|
|
155
|
+
return errors.length > 0 ? errors : null;
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Orchestrator: called from the seekMentoring handler
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* Validates all handoff contracts that apply to the given seekMentoring call.
|
|
162
|
+
* Returns an empty array when no contract is violated; otherwise returns the
|
|
163
|
+
* error strings from whichever applicable validator(s) failed.
|
|
164
|
+
*/
|
|
165
|
+
function validateHandoffContracts(args) {
|
|
166
|
+
const phase = args.currentPhase ?? '';
|
|
167
|
+
const status = args.status ?? '';
|
|
168
|
+
const evidence = args.evidence ?? {};
|
|
169
|
+
if (isSubmitPhase(phase, status)) {
|
|
170
|
+
const errors = validateReviewHandoff(evidence.reviewHandoff);
|
|
171
|
+
if (errors)
|
|
172
|
+
return errors;
|
|
173
|
+
}
|
|
174
|
+
if (isRetrospectivePhase(phase, status)) {
|
|
175
|
+
const errors = validateNextJobRecommendations(evidence.nextJobRecommendations);
|
|
176
|
+
if (errors)
|
|
177
|
+
return errors;
|
|
178
|
+
}
|
|
179
|
+
if (isDelegationGraphPhase(phase)) {
|
|
180
|
+
const errors = validateDelegationLedger(evidence.delegationLedger);
|
|
181
|
+
if (errors)
|
|
182
|
+
return errors;
|
|
183
|
+
}
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Rejection message builder
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
const REVIEW_HANDOFF_SCHEMA = `\`\`\`javascript
|
|
190
|
+
evidence: {
|
|
191
|
+
reviewHandoff: {
|
|
192
|
+
reviewRequired: true,
|
|
193
|
+
reviewTarget: { kind: "pull_request", url: "<PR URL>" } | { kind: "artifact_set", files: ["<path>"] } | null,
|
|
194
|
+
artifacts: [{ label: "<label>", path: "<path>", kind: "<kind>" }],
|
|
195
|
+
summary: "<optional summary>",
|
|
196
|
+
reviewActions: [
|
|
197
|
+
{ kind: "approve", label: "Approve" },
|
|
198
|
+
{ kind: "request_changes", label: "Request Changes" }
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
\`\`\``;
|
|
203
|
+
const NEXT_JOB_RECOMMENDATIONS_SCHEMA = `\`\`\`javascript
|
|
204
|
+
evidence: {
|
|
205
|
+
nextJobRecommendations: [
|
|
206
|
+
{
|
|
207
|
+
jobId: "<job-slug>",
|
|
208
|
+
label: "<human-readable label>",
|
|
209
|
+
reason: "<optional: why this job is recommended>",
|
|
210
|
+
contextSummary: "<optional: one sentence context for the next agent>"
|
|
211
|
+
}
|
|
212
|
+
// 0–3 entries; an empty array [] is valid
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
\`\`\``;
|
|
216
|
+
const DELEGATION_LEDGER_SCHEMA = `\`\`\`javascript
|
|
217
|
+
evidence: {
|
|
218
|
+
delegationLedger: {
|
|
219
|
+
delegationRequired: true,
|
|
220
|
+
objective: "<optional objective string>",
|
|
221
|
+
tasks: [
|
|
222
|
+
{ jobId: "<job-slug>", personaKey: "<persona key or null>", briefing: "<optional briefing>" }
|
|
223
|
+
]
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
\`\`\``;
|
|
227
|
+
const FIELD_SCHEMAS = {
|
|
228
|
+
reviewHandoff: REVIEW_HANDOFF_SCHEMA,
|
|
229
|
+
nextJobRecommendations: NEXT_JOB_RECOMMENDATIONS_SCHEMA,
|
|
230
|
+
delegationLedger: DELEGATION_LEDGER_SCHEMA,
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* Builds the rejection message returned to the agent when a handoff contract
|
|
234
|
+
* is violated. The message names the missing field and provides the required
|
|
235
|
+
* minimum schema so the agent can fix everything in one retry.
|
|
236
|
+
*/
|
|
237
|
+
function buildHandoffRejectionMessage(currentPhase, field, errors) {
|
|
238
|
+
const errorBullets = errors.map((e) => `- ${e}`).join('\n');
|
|
239
|
+
const schemaHint = FIELD_SCHEMAS[field] ?? '';
|
|
240
|
+
return [
|
|
241
|
+
`❌ **seekMentoring rejected** at phase \`${currentPhase}\`.`,
|
|
242
|
+
'',
|
|
243
|
+
`This call requires \`evidence.${field}\` to be present and valid so the Hub can render the correct management surface. The following problems were found:`,
|
|
244
|
+
'',
|
|
245
|
+
errorBullets,
|
|
246
|
+
'',
|
|
247
|
+
`Required minimum schema for \`evidence.${field}\`:`,
|
|
248
|
+
'',
|
|
249
|
+
schemaHint,
|
|
250
|
+
'',
|
|
251
|
+
`The job is **not** marked complete. Add the \`evidence.${field}\` object and resubmit this phase.`,
|
|
252
|
+
].join('\n');
|
|
253
|
+
}
|
|
@@ -63,6 +63,7 @@ const object_utils_1 = require("../core/utils/object-utils");
|
|
|
63
63
|
const local_registry_resolver_1 = require("../core/utils/local-registry-resolver");
|
|
64
64
|
const ai_mentor_1 = require("../core/ai-mentor");
|
|
65
65
|
const quality_evidence_1 = require("../core/quality-evidence");
|
|
66
|
+
const handoff_contracts_1 = require("../core/handoff-contracts");
|
|
66
67
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
67
68
|
const usage_collector_js_1 = require("./usage-collector.js");
|
|
68
69
|
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
@@ -2151,6 +2152,26 @@ class FraimLocalMCPServer {
|
|
|
2151
2152
|
this.log(`⚠️ Quality score emission failed: ${err?.message || err}`);
|
|
2152
2153
|
});
|
|
2153
2154
|
}
|
|
2155
|
+
// Handoff contract enforcement (Issue #916).
|
|
2156
|
+
//
|
|
2157
|
+
// The proxy is the primary enforcement point for reviewHandoff,
|
|
2158
|
+
// nextJobRecommendations, and delegationLedger evidence fields.
|
|
2159
|
+
// The Hub cannot render review bars, next-job chips, or the
|
|
2160
|
+
// delegation board when these fields are absent.
|
|
2161
|
+
const handoffErrors = (0, handoff_contracts_1.validateHandoffContracts)({
|
|
2162
|
+
jobName: args.jobName,
|
|
2163
|
+
currentPhase: args.currentPhase,
|
|
2164
|
+
status: args.status,
|
|
2165
|
+
evidence: args.evidence,
|
|
2166
|
+
});
|
|
2167
|
+
if (handoffErrors.length > 0) {
|
|
2168
|
+
const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
|
|
2169
|
+
: handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
|
|
2170
|
+
: 'delegationLedger';
|
|
2171
|
+
this.log(`⚠️ Handoff contract enforcement rejected seekMentoring for ${args.jobName}:${args.currentPhase}: ${handoffErrors.join('; ')}`);
|
|
2172
|
+
const rejection = (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, missingField, handoffErrors);
|
|
2173
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
|
|
2174
|
+
}
|
|
2154
2175
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
2155
2176
|
}
|
|
2156
2177
|
catch (error) {
|