fraim 2.0.227 → 2.0.228

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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.227",
3
+ "version": "2.0.228",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {