fraim-hub 2.0.339 → 2.0.340

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,617 @@
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 (job's submission phase, status === "complete";
9
+ * see isSubmitPhase for how that phase is found)
10
+ * - evidence.nextJobRecommendations (retrospective phase completion)
11
+ * - evidence.delegationLedger (create-delegation-graph phase)
12
+ *
13
+ * Lives in src/core so both the local MCP proxy and evals can import the same
14
+ * contract without cross-layer dependencies — same pattern as quality-evidence.ts.
15
+ *
16
+ * Primary call site: src/local-mcp-server/stdio-server.ts seekMentoring handler,
17
+ * after the existing quality enforcement block.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.PHASE_START_WARN_PHASES = void 0;
21
+ exports.isSubmitPhase = isSubmitPhase;
22
+ exports.isRetrospectivePhase = isRetrospectivePhase;
23
+ exports.isAddressFeedbackCompletion = isAddressFeedbackCompletion;
24
+ exports.isDelegationGraphPhase = isDelegationGraphPhase;
25
+ exports.validateAddressFeedbackApproval = validateAddressFeedbackApproval;
26
+ exports.validateReviewActionCompleteness = validateReviewActionCompleteness;
27
+ exports.hasPhaseStarted = hasPhaseStarted;
28
+ exports.upsertPhaseStarted = upsertPhaseStarted;
29
+ exports.buildPhaseStartWarning = buildPhaseStartWarning;
30
+ exports.reviewHandoffNormalizationError = reviewHandoffNormalizationError;
31
+ exports.validateReviewHandoff = validateReviewHandoff;
32
+ exports.validateNextJobRecommendations = validateNextJobRecommendations;
33
+ exports.validateDelegationLedger = validateDelegationLedger;
34
+ exports.validateHandoffContracts = validateHandoffContracts;
35
+ exports.buildHandoffRejectionMessage = buildHandoffRejectionMessage;
36
+ const resolve_phase_edge_1 = require("./resolve-phase-edge");
37
+ // ---------------------------------------------------------------------------
38
+ // Phase detection
39
+ // ---------------------------------------------------------------------------
40
+ /**
41
+ * Returns true when a seekMentoring call is a submit-phase handoff requiring
42
+ * a reviewHandoff evidence field.
43
+ *
44
+ * Two triggers, either is sufficient:
45
+ * - `status === "awaiting_mentor"`: the original #916 signal. No published
46
+ * `seekMentoring` status permits this value today (confirmed by
47
+ * `spike/1157-approval-gate/s5-dead-submit-gate.js`), so this branch is
48
+ * provably unreachable in production — kept so a future status value, or a
49
+ * job that still emits it, is not silently unenforced.
50
+ * - `status === "complete"` at the job's own submission phase, derived from
51
+ * its real phase graph (issue #1276, gating-only rebuild of #1157 Change
52
+ * 1): the unique phase whose `onSuccess` targets `address-feedback`. This
53
+ * is the trigger that actually fires. No hardcoded phase-name list — a
54
+ * personalized job with a differently-named submission phase is still
55
+ * caught, and a job whose graph doesn't uniquely name one (`phases`
56
+ * omitted, or the derivation is ambiguous) is not gated at all rather than
57
+ * guessed at.
58
+ */
59
+ function isSubmitPhase(currentPhase, status, phases) {
60
+ if (status === 'awaiting_mentor')
61
+ return true;
62
+ if (status === 'complete' && phases) {
63
+ return (0, resolve_phase_edge_1.derivePredecessorPhase)(phases, resolve_phase_edge_1.FEEDBACK_PHASE_ID) === currentPhase;
64
+ }
65
+ return false;
66
+ }
67
+ /**
68
+ * Returns true when a seekMentoring call is a retrospective completion.
69
+ * Retrospective jobs emit nextJobRecommendations at this boundary.
70
+ */
71
+ function isRetrospectivePhase(currentPhase, status) {
72
+ return currentPhase === 'retrospective' && status === 'complete';
73
+ }
74
+ /**
75
+ * Returns true when a seekMentoring call is address-feedback completing.
76
+ * Completing address-feedback requires manager approval (evidence.approved: true).
77
+ */
78
+ function isAddressFeedbackCompletion(currentPhase, status) {
79
+ return currentPhase === 'address-feedback' && status === 'complete';
80
+ }
81
+ /**
82
+ * Returns true when a seekMentoring call is the delegation graph phase.
83
+ * This matches the existing Hub test fixture for the delegation board.
84
+ */
85
+ function isDelegationGraphPhase(currentPhase) {
86
+ return currentPhase === 'create-delegation-graph';
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // Individual validators
90
+ // ---------------------------------------------------------------------------
91
+ /**
92
+ * Validates that evidence.approved is true for address-feedback completion.
93
+ * Returns null if valid, or an array of error strings.
94
+ */
95
+ function validateAddressFeedbackApproval(evidence) {
96
+ if (evidence.approved !== true) {
97
+ return [
98
+ `evidence.approved must be true when completing address-feedback. ` +
99
+ `Set evidence.approved: true only after the manager has explicitly approved the work. ` +
100
+ `If the manager has not yet approved, set status: "failure" to re-enter the review loop.`,
101
+ ];
102
+ }
103
+ return null;
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Issue #1590 — shape enforcement for review-action completeness
107
+ //
108
+ // #916/#1463 (above) validate that a reviewHandoff payload is internally
109
+ // well-formed (an enum'd reviewTarget.type, pull_request <=> empty artifacts,
110
+ // well-shaped artifact_set entries). It does not check whether the claimed
111
+ // pull_request target's reviewActions are actually complete enough to be
112
+ // useful given the repository state it describes — a structurally valid
113
+ // reviewActions array can still leave out the specific provider action the
114
+ // manager needs (issue #1541: a PR handoff shipped only the generic
115
+ // approve/request_changes pair, with no merge action of any kind). This
116
+ // validator is pure: the proxy (src/local-mcp-server/stdio-server.ts) does
117
+ // the I/O (git branch state) and passes plain data in.
118
+ //
119
+ // (Choosing artifact_set vs. pull_request for the review target itself is
120
+ // not mechanized: registry/delivery/submit.md's own Contract rules already
121
+ // state that rule in prose, and the fix for a model missing it is a
122
+ // tighter, more direct instruction plus eval coverage proving compliance,
123
+ // not a second code path re-deriving the same fact from job content.)
124
+ // ---------------------------------------------------------------------------
125
+ /** A composed `deliveryActionId` accepts a bare id or a `<domain>+<id>` suffix (registry/providers/delivery-pr.json). */
126
+ function deliveryActionIdMatches(actions, id) {
127
+ return actions.some((a) => {
128
+ const deliveryActionId = a.deliveryActionId;
129
+ return typeof deliveryActionId === 'string'
130
+ && (deliveryActionId === id || deliveryActionId.endsWith(`+${id}`));
131
+ });
132
+ }
133
+ /**
134
+ * Review-action-completeness.
135
+ *
136
+ * Only applies to a `pull_request` reviewTarget with a configured repository
137
+ * (repo: null means no repository is configured, e.g. a non-repo-backed
138
+ * job — gate does not apply). A self-consistency check against the proxy's
139
+ * own locally-observed git branch state (`detectRepoInfo()`), not a live
140
+ * GitHub/GitLab API call: it does not confirm the PR is actually open on the
141
+ * provider, only that the claimed pull_request target is backed by the
142
+ * provider actions local branch state implies it should carry.
143
+ *
144
+ * - currentBranch === defaultBranch: requires a `push_default_branch`
145
+ * deliveryActionId (bare or composed `<domain>+push_default_branch`).
146
+ * - Otherwise (feature branch, i.e. an open-PR review claimed): requires
147
+ * both `merge_pr` and `merge_pr_work_completion` (bare or composed).
148
+ */
149
+ function validateReviewActionCompleteness(reviewTarget, reviewActions, repo) {
150
+ if (!reviewTarget || reviewTarget.type !== 'pull_request')
151
+ return null;
152
+ if (!repo)
153
+ return null;
154
+ const actions = Array.isArray(reviewActions) ? reviewActions : [];
155
+ const errors = [];
156
+ if (repo.currentBranch === repo.defaultBranch) {
157
+ if (!deliveryActionIdMatches(actions, 'push_default_branch')) {
158
+ errors.push(`evidence.reviewHandoff.reviewActions must include an approve_push_default_branch action (an ` +
159
+ `approve_delivery entry whose deliveryActionId is "push_default_branch", or ends with ` +
160
+ `"+push_default_branch") — the current branch is the default branch ("${repo.defaultBranch}"), so ` +
161
+ `the manager needs a one-click way to push it.`);
162
+ }
163
+ }
164
+ else {
165
+ if (!deliveryActionIdMatches(actions, 'merge_pr')) {
166
+ errors.push(`evidence.reviewHandoff.reviewActions must include an approve_merge_pr action (an approve_delivery ` +
167
+ `entry whose deliveryActionId is "merge_pr", or ends with "+merge_pr") — an open pull request is ` +
168
+ `the claimed review target, so the manager needs a one-click way to merge it.`);
169
+ }
170
+ if (!deliveryActionIdMatches(actions, 'merge_pr_work_completion')) {
171
+ errors.push(`evidence.reviewHandoff.reviewActions must include an approve_merge_pr_work_completion action (an ` +
172
+ `approve_delivery entry whose deliveryActionId is "merge_pr_work_completion", or ends with ` +
173
+ `"+merge_pr_work_completion") — the manager also needs a one-click way to merge the PR and ` +
174
+ `complete the issue.`);
175
+ }
176
+ }
177
+ return errors.length > 0 ? errors : null;
178
+ }
179
+ /**
180
+ * True when `marker` records a "starting" timestamp for `phaseId`. Fails
181
+ * open (false, never throws) for a missing file (marker: undefined), a
182
+ * missing key, or a malformed shape — this is a warn-only signal, not a
183
+ * required artifact.
184
+ */
185
+ function hasPhaseStarted(marker, phaseId) {
186
+ if (!marker || typeof marker !== 'object' || Array.isArray(marker))
187
+ return false;
188
+ const startedPhases = marker.startedPhases;
189
+ if (!startedPhases || typeof startedPhases !== 'object' || Array.isArray(startedPhases))
190
+ return false;
191
+ const value = startedPhases[phaseId];
192
+ return typeof value === 'string' && value.length > 0;
193
+ }
194
+ /**
195
+ * Returns a new marker with `phaseId` upserted to `timestamp`, preserving
196
+ * every other phase already recorded. Tolerates a missing or malformed
197
+ * existing marker by starting from an empty `startedPhases`.
198
+ */
199
+ function upsertPhaseStarted(marker, phaseId, timestamp) {
200
+ const existing = marker && typeof marker === 'object' && !Array.isArray(marker)
201
+ ? marker.startedPhases
202
+ : undefined;
203
+ const startedPhases = existing && typeof existing === 'object' && !Array.isArray(existing)
204
+ ? { ...existing }
205
+ : {};
206
+ startedPhases[phaseId] = timestamp;
207
+ return { startedPhases };
208
+ }
209
+ /** The single phase this gate covers today (RFC: scoped, not universal — see Risk Assessment). */
210
+ exports.PHASE_START_WARN_PHASES = ['address-feedback'];
211
+ /**
212
+ * Builds the warning prepended to an otherwise-valid address-feedback
213
+ * response when this run never called seekMentoring(status: "starting") for
214
+ * that phase. Warn-only, never a rejection: a lost marker (disk cleanup, a
215
+ * first-ever run before this shipped) must not strand a legitimate resume.
216
+ */
217
+ function buildPhaseStartWarning(phaseId) {
218
+ return (`⚠️ **Phase-start notice**: this run did not call \`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` ` +
219
+ `before this call. If you resumed after context compaction, re-read \`registry/delivery/address-feedback.md\` now — ` +
220
+ `Step 4 (write the standalone feedback file) is easy to skip when acting from remembered context.\n\n`);
221
+ }
222
+ function isAbsoluteHttpUrl(url) {
223
+ if (typeof url !== 'string' || !url)
224
+ return false;
225
+ try {
226
+ const parsed = new URL(url);
227
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ }
233
+ function validateReviewArtifactEntry(artifact, index) {
234
+ const errors = [];
235
+ const label = `evidence.reviewHandoff.artifacts[${index}]`;
236
+ if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
237
+ errors.push(`${label} must be an object`);
238
+ return errors;
239
+ }
240
+ const entry = artifact;
241
+ if ('kind' in entry && !('type' in entry)) {
242
+ errors.push(`${label} uses kind but must use type — use type, not kind for artifact type (e.g. "markdown", "docx", "source_file")`);
243
+ }
244
+ else if (!entry.type || typeof entry.type !== 'string' || !entry.type.trim()) {
245
+ errors.push(`${label}.type must be a non-empty string (e.g. "markdown", "docx", "source_file")`);
246
+ }
247
+ if (!entry.label || typeof entry.label !== 'string' || !entry.label.trim()) {
248
+ errors.push(`${label}.label must be a non-empty string`);
249
+ }
250
+ if ('url' in entry) {
251
+ errors.push(`${label} must not include url — artifact_set artifacts are local files; use path`);
252
+ }
253
+ if (!entry.path || typeof entry.path !== 'string' || !entry.path.trim()) {
254
+ errors.push(`${label}.path must be a non-empty local path`);
255
+ }
256
+ return errors;
257
+ }
258
+ /**
259
+ * Reports payloads the Hub cannot normalize, including artifact_set entries it
260
+ * would silently drop. This deliberately preserves the Hub's legacy defaults;
261
+ * it does not impose the stronger, phase-dependent submission contract.
262
+ */
263
+ function reviewHandoffNormalizationError(value) {
264
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
265
+ return 'reviewHandoff must be an object';
266
+ }
267
+ const handoff = value;
268
+ if (typeof handoff.reviewRequired !== 'boolean') {
269
+ return 'reviewHandoff.reviewRequired must be a boolean';
270
+ }
271
+ // This is renderability, not the stronger submission-phase contract below.
272
+ // In particular legacy handoffs need not supply actions or an artifacts array.
273
+ if (!handoff.reviewRequired)
274
+ return null;
275
+ const target = handoff.reviewTarget;
276
+ const targetType = typeof target?.type === 'string' ? target.type.trim() : '';
277
+ if (targetType !== 'pull_request' && targetType !== 'artifact_set') {
278
+ return 'reviewHandoff.reviewTarget.type must be pull_request or artifact_set';
279
+ }
280
+ if (targetType === 'pull_request' && !isAbsoluteHttpUrl(target?.url)) {
281
+ return 'reviewHandoff.reviewTarget.url must be an absolute http or https URL';
282
+ }
283
+ // Match the host parser's object-artifact selection and Hub's path/URL
284
+ // normalization. A missing label/type is filled in by the Hub, not rejected.
285
+ const artifacts = Array.isArray(handoff.artifacts)
286
+ ? handoff.artifacts.filter((artifact) => artifact && typeof artifact === 'object')
287
+ : [];
288
+ for (const [index, artifact] of artifacts.entries()) {
289
+ const hasPath = typeof artifact.path === 'string' && artifact.path.trim().length > 0;
290
+ const hasUrl = isAbsoluteHttpUrl(artifact.url);
291
+ if (targetType === 'pull_request') {
292
+ if (hasPath || hasUrl)
293
+ return 'reviewHandoff.artifacts must be empty for a pull_request target';
294
+ }
295
+ else if (!hasPath || hasUrl) {
296
+ return `reviewHandoff.artifacts[${index}] cannot be applied: an artifact_set requires a file path without an http or https URL`;
297
+ }
298
+ }
299
+ return null;
300
+ }
301
+ /** Validate the complete submission contract, including required actions. */
302
+ function validateReviewHandoff(value) {
303
+ if (value === undefined || value === null) {
304
+ return ['evidence.reviewHandoff is missing'];
305
+ }
306
+ if (typeof value !== 'object' || Array.isArray(value)) {
307
+ return ['evidence.reviewHandoff must be an object'];
308
+ }
309
+ const obj = value;
310
+ const errors = [];
311
+ if (typeof obj.reviewRequired !== 'boolean') {
312
+ errors.push(`evidence.reviewHandoff.reviewRequired must be a boolean (got ${obj.reviewRequired === undefined ? 'missing' : typeof obj.reviewRequired})`);
313
+ }
314
+ // artifacts must always be present and be an array
315
+ if (!('artifacts' in obj) || !Array.isArray(obj.artifacts)) {
316
+ errors.push('evidence.reviewHandoff.artifacts must be an array');
317
+ }
318
+ if (obj.reviewRequired === true) {
319
+ const target = obj.reviewTarget;
320
+ if (target === undefined || target === null) {
321
+ errors.push('evidence.reviewHandoff.reviewTarget must be a non-null object with type equal to "pull_request" or "artifact_set" when reviewRequired is true');
322
+ }
323
+ else if (typeof target !== 'object' || Array.isArray(target)) {
324
+ errors.push('evidence.reviewHandoff.reviewTarget must be an object with type equal to "pull_request" or "artifact_set"');
325
+ }
326
+ else {
327
+ const t = target;
328
+ if ('kind' in t && !('type' in t)) {
329
+ errors.push(`evidence.reviewHandoff.reviewTarget uses kind but must use type — use type, not kind (e.g. { type: "pull_request", label: "...", url: "https://..." })`);
330
+ }
331
+ else if (t.type !== 'pull_request' && t.type !== 'artifact_set') {
332
+ errors.push(`evidence.reviewHandoff.reviewTarget.type must be "pull_request" or "artifact_set" (got ${t.type === undefined ? 'missing' : JSON.stringify(t.type)})`);
333
+ }
334
+ else if (t.type === 'pull_request') {
335
+ if (!isAbsoluteHttpUrl(t.url)) {
336
+ errors.push(`evidence.reviewHandoff.reviewTarget.url must be an absolute http or https URL for a pull_request target (got ${t.url === undefined ? 'missing' : JSON.stringify(t.url)})`);
337
+ }
338
+ if (Array.isArray(obj.artifacts) && obj.artifacts.length > 0) {
339
+ errors.push('evidence.reviewHandoff.artifacts must be empty for a pull_request target — the PR is the complete review surface; set artifacts: []');
340
+ }
341
+ }
342
+ else if (t.type === 'artifact_set') {
343
+ if (Array.isArray(obj.artifacts)) {
344
+ obj.artifacts.forEach((art, i) => {
345
+ errors.push(...validateReviewArtifactEntry(art, i));
346
+ });
347
+ }
348
+ }
349
+ }
350
+ if (!Array.isArray(obj.reviewActions) || obj.reviewActions.length === 0) {
351
+ errors.push('evidence.reviewHandoff.reviewActions must be a non-empty array when reviewRequired is true');
352
+ }
353
+ else {
354
+ const actions = obj.reviewActions;
355
+ const hasApprove = actions.some((a) => a.kind === 'approve');
356
+ const hasRequestChanges = actions.some((a) => a.kind === 'request_changes');
357
+ if (!hasApprove) {
358
+ errors.push('evidence.reviewHandoff.reviewActions must include an entry with kind: "approve"');
359
+ }
360
+ if (!hasRequestChanges) {
361
+ errors.push('evidence.reviewHandoff.reviewActions must include an entry with kind: "request_changes"');
362
+ }
363
+ }
364
+ }
365
+ else if (obj.reviewRequired === false && Array.isArray(obj.artifacts)) {
366
+ // For reviewRequired:false, validate any supplied artifact entries (no approval semantics required)
367
+ obj.artifacts.forEach((art, i) => {
368
+ errors.push(...validateReviewArtifactEntry(art, i));
369
+ });
370
+ }
371
+ return errors.length > 0 ? errors : null;
372
+ }
373
+ /**
374
+ * Validates evidence.nextJobRecommendations. Returns null if valid, or an
375
+ * array of error strings.
376
+ *
377
+ * Required minimum shape: an array (may be empty) of entries, each with:
378
+ * { jobId: string, label: string, reason?: string, contextSummary?: string }
379
+ */
380
+ function validateNextJobRecommendations(value) {
381
+ if (value === undefined || value === null) {
382
+ return ['evidence.nextJobRecommendations is missing'];
383
+ }
384
+ if (!Array.isArray(value)) {
385
+ return ['evidence.nextJobRecommendations must be an array'];
386
+ }
387
+ const errors = [];
388
+ if (value.length > 3) {
389
+ errors.push('evidence.nextJobRecommendations must contain at most 3 entries');
390
+ }
391
+ value.forEach((entry, i) => {
392
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
393
+ errors.push(`evidence.nextJobRecommendations[${i}] must be an object`);
394
+ return;
395
+ }
396
+ const rec = entry;
397
+ if ('jobName' in rec && !('jobId' in rec)) {
398
+ errors.push(`evidence.nextJobRecommendations[${i}] uses jobName, but runnable recommendations must use jobId. Use jobId, not jobName.`);
399
+ }
400
+ if (typeof rec.jobId !== 'string' || !rec.jobId.trim()) {
401
+ errors.push(`evidence.nextJobRecommendations[${i}].jobId must be a non-empty string`);
402
+ }
403
+ if (typeof rec.label !== 'string' || !rec.label.trim()) {
404
+ errors.push(`evidence.nextJobRecommendations[${i}].label must be a non-empty string`);
405
+ }
406
+ if (typeof rec.label === 'string' && rec.label.length > 60) {
407
+ errors.push(`evidence.nextJobRecommendations[${i}].label must be 60 characters or fewer`);
408
+ }
409
+ if (rec.reason !== undefined && typeof rec.reason !== 'string') {
410
+ errors.push(`evidence.nextJobRecommendations[${i}].reason must be a string when present`);
411
+ }
412
+ if (typeof rec.reason === 'string' && rec.reason.length > 200) {
413
+ errors.push(`evidence.nextJobRecommendations[${i}].reason must be 200 characters or fewer`);
414
+ }
415
+ if (rec.contextSummary !== undefined && typeof rec.contextSummary !== 'string') {
416
+ errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be a string when present`);
417
+ }
418
+ if (typeof rec.contextSummary === 'string' && rec.contextSummary.length > 300) {
419
+ errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be 300 characters or fewer`);
420
+ }
421
+ });
422
+ return errors.length > 0 ? errors : null;
423
+ }
424
+ /**
425
+ * Validates evidence.delegationLedger. Returns null if valid, or an array
426
+ * of error strings.
427
+ *
428
+ * Required minimum shape:
429
+ * {
430
+ * delegationRequired: boolean,
431
+ * tasks: array,
432
+ * }
433
+ */
434
+ function validateDelegationLedger(value) {
435
+ if (value === undefined || value === null) {
436
+ return ['evidence.delegationLedger is missing'];
437
+ }
438
+ if (typeof value !== 'object' || Array.isArray(value)) {
439
+ return ['evidence.delegationLedger must be an object'];
440
+ }
441
+ const obj = value;
442
+ const errors = [];
443
+ if (typeof obj.delegationRequired !== 'boolean') {
444
+ errors.push(`evidence.delegationLedger.delegationRequired must be a boolean (got ${obj.delegationRequired === undefined ? 'missing' : typeof obj.delegationRequired})`);
445
+ }
446
+ if (!('tasks' in obj)) {
447
+ errors.push('evidence.delegationLedger.tasks is missing');
448
+ }
449
+ else if (!Array.isArray(obj.tasks)) {
450
+ errors.push('evidence.delegationLedger.tasks must be an array');
451
+ }
452
+ else {
453
+ // Issue #1021: validate each task, not just the array's existence. A task naming
454
+ // no job used to pass here and then be skipped by a bare `continue` in
455
+ // maybeStartDelegatedChildRuns, so a whole workstream could vanish with nothing
456
+ // manager-visible. Rejecting at emission lets the agent retry in one round.
457
+ //
458
+ // Deliberately NOT required: `taskId`. normalizeDelegationLedger synthesizes one
459
+ // from the title, so requiring it would reject ledgers the runtime handles fine.
460
+ // The title requirement accepts `briefing`/`instructions` because the group schema
461
+ // supplies those instead — this mirrors exactly what the normalizer needs to keep
462
+ // a task, so the contract can no longer be weaker than the parser.
463
+ obj.tasks.forEach((task, index) => {
464
+ const label = `evidence.delegationLedger.tasks[${index}]`;
465
+ if (!task || typeof task !== 'object' || Array.isArray(task)) {
466
+ errors.push(`${label} must be an object`);
467
+ return;
468
+ }
469
+ const entry = task;
470
+ const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0;
471
+ if (!nonEmpty(entry.title) && !nonEmpty(entry.briefing) && !nonEmpty(entry.instructions)) {
472
+ errors.push(`${label} needs a title (or briefing/instructions to derive one from)`);
473
+ }
474
+ if (!nonEmpty(entry.jobId) && !nonEmpty(entry.job) && !nonEmpty(entry.job_id)) {
475
+ errors.push(`${label}.jobId is missing; every delegated task must name an existing FRAIM job`);
476
+ }
477
+ const issueNumber = entry.issueNumber ?? entry.issue_number ?? entry.issue;
478
+ if (issueNumber !== undefined && issueNumber !== null
479
+ && typeof issueNumber !== 'string' && typeof issueNumber !== 'number') {
480
+ errors.push(`${label}.issueNumber must be a string or number when present (got ${typeof issueNumber})`);
481
+ }
482
+ });
483
+ }
484
+ return errors.length > 0 ? errors : null;
485
+ }
486
+ // ---------------------------------------------------------------------------
487
+ // Orchestrator: called from the seekMentoring handler
488
+ // ---------------------------------------------------------------------------
489
+ /**
490
+ * Validates all handoff contracts that apply to the given seekMentoring call.
491
+ * Returns an empty array when no contract is violated; otherwise returns the
492
+ * error strings from whichever applicable validator(s) failed.
493
+ */
494
+ function validateHandoffContracts(args) {
495
+ const phase = args.currentPhase ?? '';
496
+ const status = args.status ?? '';
497
+ const evidence = args.evidence ?? {};
498
+ const findings = args.findings ?? {};
499
+ if (isSubmitPhase(phase, status, args.phases) && !evidence.reviewHandoff && findings.reviewHandoff) {
500
+ return ['reviewHandoff was found in findings but must be nested under evidence. Move reviewHandoff out of findings and into the top-level evidence object.'];
501
+ }
502
+ if (isRetrospectivePhase(phase, status) && !evidence.nextJobRecommendations && findings.nextJobRecommendations) {
503
+ return ['nextJobRecommendations was found in findings but must be nested under evidence. Move nextJobRecommendations out of findings and into the top-level evidence object.'];
504
+ }
505
+ if (isSubmitPhase(phase, status, args.phases)) {
506
+ const structuralErrors = validateReviewHandoff(evidence.reviewHandoff);
507
+ if (structuralErrors)
508
+ return structuralErrors;
509
+ // Issue #1590: a structurally valid reviewHandoff can still leave out
510
+ // the specific provider action the claimed repository state requires
511
+ // (issue #1541). Shares reviewHandoff's FRAIM_HANDOFF_ENFORCEMENT_MODE
512
+ // escape hatch (see stdio-server.ts). No-ops when repo is absent.
513
+ const reviewHandoff = evidence.reviewHandoff;
514
+ const reviewTarget = reviewHandoff?.reviewTarget;
515
+ const reviewActions = reviewHandoff?.reviewActions;
516
+ const actionCompletenessErrors = validateReviewActionCompleteness(reviewTarget, reviewActions, args.repo ?? null);
517
+ if (actionCompletenessErrors)
518
+ return actionCompletenessErrors;
519
+ }
520
+ if (isRetrospectivePhase(phase, status)) {
521
+ const errors = validateNextJobRecommendations(evidence.nextJobRecommendations);
522
+ if (errors)
523
+ return errors;
524
+ }
525
+ if (isDelegationGraphPhase(phase)) {
526
+ const errors = validateDelegationLedger(evidence.delegationLedger);
527
+ if (errors)
528
+ return errors;
529
+ }
530
+ if (isAddressFeedbackCompletion(phase, status)) {
531
+ const errors = validateAddressFeedbackApproval(evidence);
532
+ if (errors)
533
+ return errors;
534
+ }
535
+ return [];
536
+ }
537
+ // ---------------------------------------------------------------------------
538
+ // Rejection message builder
539
+ // ---------------------------------------------------------------------------
540
+ const ADDRESS_FEEDBACK_APPROVAL_SCHEMA = `\`\`\`javascript
541
+ evidence: {
542
+ approved: true // Set only after the manager explicitly approves via the Hub review action
543
+ }
544
+ \`\`\``;
545
+ const REVIEW_HANDOFF_SCHEMA = `\`\`\`javascript
546
+ evidence: {
547
+ reviewHandoff: {
548
+ reviewRequired: true,
549
+ // Pull request review (PR is the complete review surface -- set artifacts: []):
550
+ reviewTarget: { type: "pull_request", label: "Pull request", url: "https://github.com/org/repo/pull/N" },
551
+ artifacts: [],
552
+ // -- OR -- artifact set review (local generated files):
553
+ // reviewTarget: { type: "artifact_set", label: "Review bundle" },
554
+ // artifacts: [{ type: "markdown", label: "<label>", path: "<local-path>" }],
555
+ summary: "<optional summary>",
556
+ reviewActions: [
557
+ { kind: "approve", label: "Approve" },
558
+ { kind: "request_changes", label: "Request Changes" }
559
+ ]
560
+ }
561
+ }
562
+ \`\`\`
563
+ Note: reviewTarget uses "type" (not "kind"). Action entries use "kind" -- these are different fields.`;
564
+ const NEXT_JOB_RECOMMENDATIONS_SCHEMA = `\`\`\`javascript
565
+ evidence: {
566
+ nextJobRecommendations: [
567
+ {
568
+ jobId: "<job-slug>",
569
+ label: "<human-readable label>",
570
+ reason: "<optional: why this job is recommended>",
571
+ contextSummary: "<optional: one sentence context for the next agent>"
572
+ }
573
+ // 0–3 entries; an empty array [] is valid
574
+ ]
575
+ }
576
+ \`\`\``;
577
+ const DELEGATION_LEDGER_SCHEMA = `\`\`\`javascript
578
+ evidence: {
579
+ delegationLedger: {
580
+ delegationRequired: true,
581
+ objective: "<optional objective string>",
582
+ tasks: [
583
+ { jobId: "<job-slug>", personaKey: "<persona key or null>", briefing: "<optional briefing>" }
584
+ ]
585
+ }
586
+ }
587
+ \`\`\``;
588
+ const FIELD_SCHEMAS = {
589
+ approved: ADDRESS_FEEDBACK_APPROVAL_SCHEMA,
590
+ reviewHandoff: REVIEW_HANDOFF_SCHEMA,
591
+ nextJobRecommendations: NEXT_JOB_RECOMMENDATIONS_SCHEMA,
592
+ delegationLedger: DELEGATION_LEDGER_SCHEMA,
593
+ };
594
+ /**
595
+ * Builds the rejection message returned to the agent when a handoff contract
596
+ * is violated. The message names the missing field and provides the required
597
+ * minimum schema so the agent can fix everything in one retry.
598
+ */
599
+ function buildHandoffRejectionMessage(currentPhase, field, errors) {
600
+ const errorBullets = errors.map((e) => `- ${e}`).join('\n');
601
+ const schemaHint = FIELD_SCHEMAS[field] ?? '';
602
+ return [
603
+ `❌ **seekMentoring rejected** at phase \`${currentPhase}\`.`,
604
+ '',
605
+ `This call requires \`evidence.${field}\` to be present and valid so the Hub can render the correct management surface. The following problems were found:`,
606
+ '',
607
+ errorBullets,
608
+ '',
609
+ `Required minimum schema for \`evidence.${field}\`:`,
610
+ '',
611
+ schemaHint,
612
+ '',
613
+ field === 'approved'
614
+ ? 'The job is **not** marked complete. Set `evidence.approved: true` (only after explicit manager approval) and resubmit.'
615
+ : `The job is **not** marked complete. Add the \`evidence.${field}\` object and resubmit this phase.`,
616
+ ].join('\n');
617
+ }