fraim 2.0.362-next.0 → 2.0.362-next.3

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.
@@ -72,7 +72,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
72
72
  personaKey: 'qasm',
73
73
  bundleId: 'persona-qasm-core',
74
74
  catalogMetadata: buildCatalogMetadata('qasm', ['test-authoring', 'iterative-quality-improvement', 'ui-polish-validation']),
75
- protectedJobs: ['test-authoring', 'ui-polish-validation', 'codebase-quality-assessment', 'test-quality-assessment', 'user-testing-and-bug-bash', 'iterative-quality-improvement', 'accessibility-audit', 'api-testing', 'stress-test-app-under-load', 'performance-quality-assessment'],
75
+ protectedJobs: ['test-authoring', 'mutation-testing', 'ui-polish-validation', 'codebase-quality-assessment', 'test-quality-assessment', 'user-testing-and-bug-bash', 'iterative-quality-improvement', 'accessibility-audit', 'api-testing', 'stress-test-app-under-load', 'performance-quality-assessment'],
76
76
  protectedAliases: ['qa', 'quality-assurance'],
77
77
  defaultHireMode: 'job',
78
78
  lockCopy: 'Hire QAsm to unlock QA validation for this request.'
@@ -2,12 +2,13 @@
2
2
  /**
3
3
  * Handoff Contract Validation (Issue #916)
4
4
  *
5
- * Pure helpers for validating the three evidence fields that FRAIM jobs must
5
+ * Pure helpers for validating evidence fields that FRAIM jobs
6
6
  * emit on key seekMentoring calls so the Hub can render its review surfaces:
7
7
  *
8
8
  * - evidence.reviewHandoff (job's submission phase, status === "complete";
9
9
  * see isSubmitPhase for how that phase is found)
10
- * - evidence.nextJobRecommendations (optional at submission, or completion for jobs without submission)
10
+ * - evidence.nextJobRecommendations (optional at submission or an explicit standalone recommendation step)
11
+ * - evidence.finalizationOutcome (explicit result of the approved assignment)
11
12
  * - evidence.delegationLedger (create-delegation-graph phase)
12
13
  *
13
14
  * Lives in src/core so both the local MCP proxy and evals can import the same
@@ -18,7 +19,6 @@
18
19
  */
19
20
  Object.defineProperty(exports, "__esModule", { value: true });
20
21
  exports.isSubmitPhase = isSubmitPhase;
21
- exports.isRetrospectivePhase = isRetrospectivePhase;
22
22
  exports.isAddressFeedbackCompletion = isAddressFeedbackCompletion;
23
23
  exports.isDelegationGraphPhase = isDelegationGraphPhase;
24
24
  exports.validateAddressFeedbackApproval = validateAddressFeedbackApproval;
@@ -33,6 +33,7 @@ exports.reviewHandoffNormalizationError = reviewHandoffNormalizationError;
33
33
  exports.validateReviewHandoff = validateReviewHandoff;
34
34
  exports.validateNextJobRecommendations = validateNextJobRecommendations;
35
35
  exports.validateDelegationLedger = validateDelegationLedger;
36
+ exports.validateFinalizationOutcome = validateFinalizationOutcome;
36
37
  exports.validateHandoffContracts = validateHandoffContracts;
37
38
  exports.buildHandoffRejectionMessage = buildHandoffRejectionMessage;
38
39
  const resolve_phase_edge_1 = require("./resolve-phase-edge");
@@ -66,13 +67,6 @@ function isSubmitPhase(currentPhase, status, phases) {
66
67
  }
67
68
  return false;
68
69
  }
69
- /**
70
- * Returns true when a seekMentoring call is a retrospective completion.
71
- * Retrospective jobs emit nextJobRecommendations at this boundary.
72
- */
73
- function isRetrospectivePhase(currentPhase, status) {
74
- return currentPhase === 'retrospective' && status === 'complete';
75
- }
76
70
  /**
77
71
  * Returns true when a seekMentoring call is address-feedback completing.
78
72
  * Completing address-feedback requires manager approval (evidence.approved: true).
@@ -594,6 +588,24 @@ function validateDelegationLedger(value) {
594
588
  }
595
589
  return errors.length > 0 ? errors : null;
596
590
  }
591
+ /** Validate an explicitly supplied whole-assignment report, never infer one. */
592
+ function validateFinalizationOutcome(value, phaseStatus) {
593
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
594
+ return ['evidence.finalizationOutcome must be an object'];
595
+ }
596
+ const result = value;
597
+ const errors = [];
598
+ if (!['success', 'failure', 'incomplete'].includes(result.status)) {
599
+ errors.push('evidence.finalizationOutcome.status must be success, failure, or incomplete');
600
+ }
601
+ else if (phaseStatus !== undefined && phaseStatus !== (result.status === 'success' ? 'complete' : result.status)) {
602
+ errors.push('evidence.finalizationOutcome.status must match the reporting phase status (success uses complete)');
603
+ }
604
+ if (typeof result.summary !== 'string' || !result.summary.trim()) {
605
+ errors.push('evidence.finalizationOutcome.summary must describe the actual result');
606
+ }
607
+ return errors.length ? errors : null;
608
+ }
597
609
  // ---------------------------------------------------------------------------
598
610
  // Orchestrator: called from the seekMentoring handler
599
611
  // ---------------------------------------------------------------------------
@@ -610,8 +622,10 @@ function validateHandoffContracts(args) {
610
622
  if (isSubmitPhase(phase, status, args.phases) && !evidence.reviewHandoff && findings.reviewHandoff) {
611
623
  return ['reviewHandoff was found in findings but must be nested under evidence. Move reviewHandoff out of findings and into the top-level evidence object.'];
612
624
  }
613
- if (isRetrospectivePhase(phase, status) && !evidence.nextJobRecommendations && findings.nextJobRecommendations) {
614
- return ['nextJobRecommendations was found in findings but must be nested under evidence. Move nextJobRecommendations out of findings and into the top-level evidence object.'];
625
+ if (Object.prototype.hasOwnProperty.call(evidence, 'finalizationOutcome')) {
626
+ const errors = validateFinalizationOutcome(evidence.finalizationOutcome, status);
627
+ if (errors)
628
+ return errors;
615
629
  }
616
630
  if (isSubmitPhase(phase, status, args.phases)) {
617
631
  const structuralErrors = validateReviewHandoff(evidence.reviewHandoff);
@@ -628,7 +642,11 @@ function validateHandoffContracts(args) {
628
642
  if (actionCompletenessErrors)
629
643
  return actionCompletenessErrors;
630
644
  }
631
- if (isRetrospectivePhase(phase, status)) {
645
+ // Reviewed jobs own candidates at submission. Retrospective must neither
646
+ // require nor reinterpret a historical candidate payload. Standalone jobs
647
+ // can still validate an explicitly supplied recommendation at their own step.
648
+ if (phase !== 'retrospective' && !isSubmitPhase(phase, status, args.phases)
649
+ && Object.prototype.hasOwnProperty.call(evidence, 'nextJobRecommendations')) {
632
650
  const errors = validateNextJobRecommendations(evidence.nextJobRecommendations);
633
651
  if (errors)
634
652
  return errors;
@@ -727,6 +745,7 @@ const FIELD_SCHEMAS = {
727
745
  approved: ADDRESS_FEEDBACK_APPROVAL_SCHEMA,
728
746
  reviewHandoff: REVIEW_HANDOFF_SCHEMA,
729
747
  nextJobRecommendations: NEXT_JOB_RECOMMENDATIONS_SCHEMA,
748
+ finalizationOutcome: 'evidence: { finalizationOutcome: { status: "success" | "failure" | "incomplete", summary: "<actual result of the approved assignment>" } }. Use phase status "complete" for success, and the matching phase status for failure or incomplete. No transport identifiers or receipt are required.',
730
749
  delegationLedger: DELEGATION_LEDGER_SCHEMA,
731
750
  };
732
751
  /**
@@ -4,12 +4,12 @@ exports.reviewDigest = reviewDigest;
4
4
  exports.createReviewLifecycle = createReviewLifecycle;
5
5
  exports.activateReviewJob = activateReviewJob;
6
6
  exports.activeReview = activeReview;
7
- exports.currentReviewTerminalReceipt = currentReviewTerminalReceipt;
8
- exports.obligationDigest = obligationDigest;
9
7
  exports.submitReviewRevision = submitReviewRevision;
10
8
  exports.decideReviewRevision = decideReviewRevision;
11
- exports.acceptReviewReceipt = acceptReviewReceipt;
12
- exports.unsupportedReviewDeliveryActions = unsupportedReviewDeliveryActions;
9
+ exports.recordReviewOutcome = recordReviewOutcome;
10
+ exports.reserveOutcomeReportRequest = reserveOutcomeReportRequest;
11
+ exports.recommendationsVisible = recommendationsVisible;
12
+ exports.upgradeReviewLifecycle = upgradeReviewLifecycle;
13
13
  exports.projectReviewLifecycle = projectReviewLifecycle;
14
14
  exports.normalizeReviewCandidates = normalizeReviewCandidates;
15
15
  exports.candidateReadiness = candidateReadiness;
@@ -20,60 +20,36 @@ function reviewDigest(value) {
20
20
  return (0, crypto_1.createHash)('sha256').update(JSON.stringify(canonical(value))).digest('hex');
21
21
  }
22
22
  function createReviewLifecycle(source) {
23
- return { version: 1, sequence: 0, source, jobs: {}, reviewsByJobInstance: {}, operations: {}, consumedReceipts: [] };
23
+ return { version: 2, sequence: 0, source, jobs: {}, reviewsByJobInstance: {}, operations: {} };
24
24
  }
25
25
  function activateReviewJob(state, job) {
26
26
  state.jobs[job.jobInstanceId] = { ...job, ...(state.jobs[job.jobInstanceId]?.manualClosure ? { manualClosure: state.jobs[job.jobInstanceId].manualClosure } : {}) };
27
27
  state.activeJob = state.jobs[job.jobInstanceId];
28
28
  }
29
29
  function activeReview(state) {
30
- return state?.activeJob ? state.reviewsByJobInstance[state.activeJob.jobInstanceId] : undefined;
30
+ return state && (state.version === 1 || state.version === 2) && state.activeJob
31
+ ? state.reviewsByJobInstance[state.activeJob.jobInstanceId] : undefined;
31
32
  }
32
- function currentReviewTerminalReceipt(state) {
33
- const job = state?.activeJob;
34
- const review = activeReview(state);
35
- const receipt = review?.terminalReceipt;
36
- return receipt && job && receipt.sourceRunId === state?.source.runId && receipt.jobInstanceId === job.jobInstanceId
37
- && receipt.jobName === job.jobName && receipt.workflowDigest === review?.workflowDigest
38
- && receipt.sessionId === job.sessionId && receipt.executionEpoch === job.executionEpoch
39
- && receipt.revisionId === review?.revisionId && receipt.obligationSetDigest === obligationDigest(review.obligations)
40
- ? receipt : undefined;
41
- }
42
- function obligationDigest(obligations) {
43
- // Verification outcomes change as delivery completes; membership and identity do not.
44
- return reviewDigest(obligations.map(({ id, kind, subject, issueNumber, childRunId, childJobName, childJobInstanceId, childRevisionId }) => ({ id, kind, subject, issueNumber, childRunId, childJobName, childJobInstanceId, childRevisionId })).sort((a, b) => a.id.localeCompare(b.id)));
45
- }
46
- function submitReviewRevision(state, input, resubmission = false) {
47
- const manifest = [...input.manifest].sort((a, b) => a.id.localeCompare(b.id));
48
- const target = input.handoff.reviewTarget;
49
- const revisionId = reviewDigest({ source: state.source, owner: input.jobInstanceId, jobName: input.jobName, workflowDigest: input.workflowDigest,
50
- target: target ? { type: target.type, url: target.url || undefined } : undefined, manifest });
33
+ function submitReviewRevision(state, input) {
51
34
  const old = state.reviewsByJobInstance[input.jobInstanceId];
35
+ const submissionEventId = input.submissionEventId || (0, crypto_1.randomUUID)();
36
+ if (old && (old.submissionEventId === submissionEventId || old.history.some(item => item.submissionEventId === submissionEventId)))
37
+ return old;
38
+ const manifest = [...(input.manifest || [])];
39
+ const revisionId = reviewDigest({ conversation: state.source.conversationId, owner: input.jobInstanceId, submissionEventId });
52
40
  const actionSetDigest = reviewDigest(input.handoff.reviewActions || []);
53
41
  const candidates = input.candidates.map(c => {
54
42
  const candidateId = reviewDigest({ revisionId, ...candidateIdentity(c) });
55
43
  return { ...c, candidateId, revisionId, sourceRunId: state.source.runId, sourceConversationId: state.source.conversationId };
56
44
  }).filter((c, index, all) => all.findIndex(other => other.candidateId === c.candidateId) === index).slice(0, 3);
57
- if (old?.revisionId === revisionId && old.actionSetDigest === actionSetDigest) {
58
- if (resubmission && old.decision?.status === 'changes_requested') {
59
- old.history.push({ revisionId: old.revisionId, decision: old.decision, manifest: old.manifest, handoff: old.handoff });
60
- delete old.decision;
61
- delete old.terminalReceipt;
62
- old.obligations = [];
63
- }
64
- old.handoff = input.handoff;
65
- old.candidates = candidates.map(candidate => ({ ...candidate,
66
- ...(old.candidates.find(prior => prior.candidateId === candidate.candidateId)?.startedRunId
67
- ? { startedRunId: old.candidates.find(prior => prior.candidateId === candidate.candidateId).startedRunId } : {}),
68
- }));
69
- return old;
70
- }
71
45
  const review = {
72
- jobInstanceId: input.jobInstanceId, jobName: input.jobName, workflowDigest: input.workflowDigest,
46
+ jobInstanceId: input.jobInstanceId, jobName: input.jobName, workflowDigest: input.workflowDigest || '', submissionEventId,
73
47
  handoff: input.handoff, manifest, revisionId, actionSetDigest, candidates, obligations: [],
74
- history: old ? [...old.history, { revisionId: old.revisionId, decision: old.decision, terminalReceipt: old.terminalReceipt, manifest: old.manifest, handoff: old.handoff }] : [],
48
+ history: old ? [...old.history, { revisionId: old.revisionId, submissionEventId: old.submissionEventId, decision: old.decision, outcome: old.outcome, terminalReceipt: old.terminalReceipt, manifest: old.manifest, handoff: old.handoff }] : [],
75
49
  };
76
50
  state.reviewsByJobInstance[input.jobInstanceId] = review;
51
+ if (state.jobs[input.jobInstanceId])
52
+ state.jobs[input.jobInstanceId].state = 'working';
77
53
  return review;
78
54
  }
79
55
  function decideReviewRevision(state, input, obligations = []) {
@@ -98,60 +74,101 @@ function decideReviewRevision(state, input, obligations = []) {
98
74
  review.decision = { ...input, status: action.kind === 'request_changes' ? 'changes_requested' : 'approved', at: new Date().toISOString(), action };
99
75
  review.obligations = obligations;
100
76
  delete review.terminalReceipt;
77
+ delete review.outcome;
78
+ delete review.reportRequest;
79
+ delete review.legacyCompletion;
80
+ if (state.activeJob)
81
+ state.activeJob.state = 'working';
101
82
  const operation = { id: input.decisionId, kind: 'approval', payloadDigest: reviewDigest(input), state: 'reserved', jobInstanceId: review.jobInstanceId, revisionId: review.revisionId };
102
83
  state.operations[operation.id] = operation;
103
84
  return { duplicate: false, review, operation };
104
85
  }
105
- function acceptReviewReceipt(state, receipt) {
106
- if (state.consumedReceipts.includes(receipt.acceptanceId))
86
+ function boundReview(state, binding) {
87
+ const review = state.reviewsByJobInstance[binding.jobInstanceId];
88
+ const job = state.jobs[binding.jobInstanceId];
89
+ if (!job || job.manualClosure || !review || review.revisionId !== binding.revisionId
90
+ || review.decision?.status !== 'approved' || review.decision.decisionId !== binding.decisionId
91
+ || binding.attemptId !== undefined && binding.attemptId !== job.executionEpoch)
92
+ return undefined;
93
+ return review;
94
+ }
95
+ /** The server supplies this binding from the captured launch, never from employee arguments. */
96
+ function recordReviewOutcome(state, input) {
97
+ const review = boundReview(state, input);
98
+ if (!review || !input.eventId || !input.outcome || !['success', 'failure', 'incomplete'].includes(input.outcome.status)
99
+ || typeof input.outcome.summary !== 'string' || !input.outcome.summary.trim())
107
100
  return false;
108
- const review = state.reviewsByJobInstance[receipt.jobInstanceId];
109
- const job = state.jobs[receipt.jobInstanceId];
110
- if (!review || !job || receipt.sourceRunId !== state.source.runId || receipt.jobName !== review.jobName
111
- || receipt.sessionId !== job.sessionId || receipt.executionEpoch !== job.executionEpoch
112
- || receipt.workflowDigest !== review.workflowDigest || receipt.revisionId !== review.revisionId)
101
+ if (review.outcome?.eventId === input.eventId || review.outcomeHistory?.some(item => item.eventId === input.eventId))
113
102
  return false;
114
- if (receipt.requestedStatus === 'complete' && receipt.outcome === 'terminal'
115
- && receipt.obligationSetDigest === obligationDigest(review.obligations)) {
116
- review.terminalReceipt = receipt;
117
- state.consumedReceipts.push(receipt.acceptanceId);
118
- return true;
119
- }
120
- return false;
103
+ if (review.outcome)
104
+ (review.outcomeHistory ||= []).push(review.outcome);
105
+ review.outcome = { ...input.outcome, summary: input.outcome.summary.trim(), eventId: input.eventId,
106
+ at: input.at || new Date().toISOString(), ...(input.attemptId ? { attemptId: input.attemptId } : {}) };
107
+ return true;
108
+ }
109
+ function reserveOutcomeReportRequest(state, binding) {
110
+ const review = boundReview(state, binding);
111
+ if (!review || review.outcome || review.legacyCompletion || review.reportRequest && review.reportRequest.state !== 'unrequested')
112
+ return false;
113
+ review.reportRequest = { state: 'reserved', revisionId: binding.revisionId, decisionId: binding.decisionId,
114
+ attemptId: binding.attemptId, at: new Date().toISOString() };
115
+ return true;
121
116
  }
122
- function unsupportedReviewDeliveryActions(subject) {
123
- return subject.split('+').filter(action => !['merge_pr', 'merge_pr_work_completion', 'cleanup_branch'].includes(action));
117
+ function recommendationsVisible(review) {
118
+ return review?.decision?.status === 'approved' && (!review.outcome || review.outcome.status === 'success');
119
+ }
120
+ /** Version-one facts are retained as history; they no longer authorize future completion. */
121
+ function upgradeReviewLifecycle(state) {
122
+ if (state.version !== 1)
123
+ return state;
124
+ for (const review of Object.values(state.reviewsByJobInstance)) {
125
+ const job = state.jobs[review.jobInstanceId];
126
+ const receipt = review.terminalReceipt;
127
+ const legacyMembership = reviewDigest(review.obligations.map(({ id, kind, subject, issueNumber, childRunId, childJobName, childJobInstanceId, childRevisionId }) => ({ id, kind, subject, issueNumber, childRunId, childJobName, childJobInstanceId, childRevisionId })).sort((a, b) => a.id.localeCompare(b.id)));
128
+ if (job && !['error', 'waiting', 'scheduled'].includes(job.state) && !job.manualClosure && review.decision?.status === 'approved' && !review.verificationError
129
+ && receipt?.sessionId === job.sessionId && receipt.executionEpoch === job.executionEpoch
130
+ && receipt.revisionId === review.revisionId && receipt.obligationSetDigest === legacyMembership
131
+ && review.obligations.every(item => item.state === 'succeeded'))
132
+ review.legacyCompletion = { provenance: 'version-1' };
133
+ if (review.verificationError) {
134
+ review.outcomeErrors = [...(review.outcomeErrors || []), review.verificationError];
135
+ delete review.verificationError;
136
+ }
137
+ }
138
+ state.version = 2;
139
+ return state;
124
140
  }
125
141
  function projectReviewLifecycle(state) {
142
+ upgradeReviewLifecycle(state);
143
+ // Unknown versions are opaque: even their active-job/review layout may differ.
144
+ // The presentation schema remains supported so clients can display the error.
145
+ if (state.version !== 2)
146
+ return { version: 2,
147
+ sequence: typeof state.sequence === 'number' && Number.isFinite(state.sequence) && state.sequence >= 0 ? state.sequence : 0,
148
+ displayState: 'error', recommendationsVisible: false, unsupportedVersion: state.version,
149
+ attentionReason: 'This review was saved by a newer version of Hub.' };
126
150
  const job = state.activeJob;
127
151
  const review = activeReview(state);
128
- const view = { version: 1, sequence: state.sequence, jobInstanceId: job?.jobInstanceId,
152
+ const view = { version: state.version, sequence: state.sequence, jobInstanceId: job?.jobInstanceId,
129
153
  revisionId: review?.revisionId, actionSetDigest: review?.actionSetDigest,
130
- decisionStatus: review ? review.decision?.status || 'pending' : undefined, displayState: 'working' };
154
+ decisionStatus: review ? review.decision?.status || 'pending' : undefined, displayState: 'working',
155
+ ...(review?.outcome ? { outcomeStatus: review.outcome.status } : {}), recommendationsVisible: recommendationsVisible(review) };
131
156
  if (job?.manualClosure)
132
- return { ...view, displayState: 'closed_manually', attentionReason: 'Closed manually. Delivery and unresolved obligations remain unverified.' };
157
+ return { ...view, displayState: 'closed_manually', attentionReason: 'Closed manually; no employee success was inferred.' };
133
158
  if (job?.state === 'error')
134
159
  return { ...view, displayState: 'error', attentionReason: 'The active job needs recovery.' };
135
160
  if (job?.state === 'scheduled')
136
161
  return { ...view, displayState: 'scheduled' };
137
- if (job?.state === 'waiting')
138
- return { ...view, displayState: 'waiting', attentionReason: 'The active job needs a decision.' };
139
162
  if (!review)
140
163
  return { ...view, displayState: job?.state === 'complete' ? 'complete' : 'working' };
141
- if (review.verificationError)
142
- return { ...view, displayState: 'error', attentionReason: review.verificationError };
143
164
  if (!review.decision || review.decision.status === 'changes_requested')
144
165
  return { ...view, displayState: 'waiting', attentionReason: review.decision ? 'Changes requested.' : 'Review the current result.' };
145
- if (review.obligations.some(o => o.state === 'failed'))
146
- return { ...view, displayState: 'error', attentionReason: 'Required delivery failed.' };
147
- if (review.terminalReceipt && review.terminalReceipt.sessionId === job?.sessionId
148
- && review.terminalReceipt.executionEpoch === job?.executionEpoch && review.terminalReceipt.revisionId === review.revisionId
149
- && review.terminalReceipt.obligationSetDigest === obligationDigest(review.obligations)
150
- && review.obligations.every(o => o.state === 'succeeded'))
166
+ if (review.outcome?.status === 'failure' || review.outcome?.status === 'incomplete')
167
+ return { ...view, displayState: 'waiting', attentionReason: review.outcome.summary };
168
+ if (review.outcome?.status === 'success' || review.legacyCompletion)
151
169
  return { ...view, displayState: 'complete' };
152
- const unsupported = review.obligations.filter(o => o.kind === 'delivery' && o.state !== 'succeeded')
153
- .flatMap(o => unsupportedReviewDeliveryActions(o.subject));
154
- return { ...view, displayState: 'finalizing', ...(unsupported.length ? { attentionReason: `Cannot automatically verify ${[...new Set(unsupported)].join(', ')}. Mark complete can close the job manually without confirming delivery.` } : {}) };
170
+ return { ...view, displayState: 'finalizing', ...(review.reportRequest && review.reportRequest.state !== 'unrequested'
171
+ ? { attentionReason: 'Waiting for the employee to report the final result.' } : {}) };
155
172
  }
156
173
  function candidateIdentity(candidate) {
157
174
  return { jobId: candidate.jobId,
@@ -170,30 +187,28 @@ function normalizeReviewCandidates(value) {
170
187
  .slice(0, 3).map(c => ({ jobId: c.jobId, label: c.label, reason: typeof c.reason === 'string' ? c.reason : undefined,
171
188
  contextSummary: typeof c.contextSummary === 'string' ? c.contextSummary : undefined, artifactIds: c.artifactIds, prerequisites: c.prerequisites }));
172
189
  }
173
- function candidateReadiness(review, candidate, available) {
190
+ function candidateReadiness(review, candidate, available, context = {}) {
174
191
  if (!available)
175
192
  return { readiness: 'unavailable', readinessReason: 'Unavailable in this project.' };
176
- if (review.verificationError)
177
- return { readiness: 'unavailable', readinessReason: review.verificationError };
178
- if (review.decision?.status !== 'approved')
179
- return { readiness: 'waiting', readinessReason: 'Waiting for approval of this result.' };
193
+ if (!recommendationsVisible(review))
194
+ return { readiness: 'waiting', readinessReason: review.outcome?.summary || 'Waiting for approval of this result.' };
180
195
  for (const p of candidate.prerequisites || []) {
181
- const artifact = review.manifest.find(a => a.id === p.reference);
182
- const obligation = review.obligations.find(o => o.id === p.reference || o.subject === p.reference);
183
- const met = p.kind === 'approved_artifact' ? !!artifact
184
- : p.kind === 'delivered_artifact' ? !!artifact
185
- && review.obligations.some(o => o.kind === 'artifact' && o.subject === artifact.id && o.state === 'succeeded')
186
- // Matching local bytes prove integrity, not publication or receipt at a destination.
187
- // Delivery remains unknown until the selected action's owning verifier supplies proof.
188
- && review.obligations.some(o => o.kind === 'delivery')
189
- && review.obligations.filter(o => o.kind === 'delivery').every(o => o.state === 'succeeded' && !!o.evidenceRef)
190
- : p.kind === 'selected_action_success' ? obligation?.kind === 'delivery' && obligation.state === 'succeeded'
191
- : p.kind === 'required_child_success' ? obligation?.kind === 'child' && obligation.state === 'succeeded'
192
- : false; // External facts require an owning provider verifier; absence is never Ready.
196
+ const artifact = hasArtifactReference(review, p.reference);
197
+ const reportedSuccess = review.outcome?.status === 'success';
198
+ const met = p.kind === 'approved_artifact' ? artifact
199
+ : p.kind === 'delivered_artifact' ? artifact && reportedSuccess
200
+ : p.kind === 'selected_action_success' ? reportedSuccess && !!review.decision?.action.deliveryActionId
201
+ && [review.decision.action.id, review.decision.action.deliveryActionId, `delivery:${review.decision.action.deliveryActionId}`].includes(p.reference)
202
+ : (context.reportedPrerequisites || []).some(reported => reported.kind === p.kind && reported.reference === p.reference);
193
203
  if (!met)
194
204
  return { readiness: 'waiting', readinessReason: `Waiting for ${p.kind.replace(/_/g, ' ')}: ${p.reference}.` };
195
205
  }
196
- if (candidate.artifactIds?.some(id => !review.manifest.some(a => a.id === id)))
206
+ if (candidate.artifactIds?.some(id => !hasArtifactReference(review, id)))
197
207
  return { readiness: 'unavailable', readinessReason: 'A referenced artifact is unavailable.' };
198
208
  return { readiness: 'ready', readinessReason: 'Ready' };
199
209
  }
210
+ function hasArtifactReference(review, reference) {
211
+ return review.manifest.some(item => item.id === reference || item.locator === reference)
212
+ || review.handoff.artifacts.some(item => item.path === reference || item.url === reference || item.label === reference)
213
+ || review.handoff.reviewTarget?.url === reference;
214
+ }
@@ -72,11 +72,15 @@ function activeReviewReferences() {
72
72
  }
73
73
  const complete = (record) => {
74
74
  const state = record.reviewLifecycle;
75
- if (state && state.version !== 1)
75
+ if (state && state.version !== 1 && state.version !== 2)
76
76
  throw new Error('Unknown review version');
77
77
  return state ? (0, review_lifecycle_1.projectReviewLifecycle)(state).displayState === 'complete'
78
78
  : record.status === 'completed' && record.pauseReason === 'done';
79
79
  };
80
+ const handoffReferences = (handoff) => [
81
+ ...(handoff?.artifacts || []).flatMap(artifact => artifact.path ? [{ locator: artifact.path }] : []),
82
+ ...(handoff?.reviewTarget?.url ? [{ locator: handoff.reviewTarget.url }] : []),
83
+ ];
80
84
  const protect = (manifest, home) => {
81
85
  if (!Array.isArray(manifest) || !home || !node_path_1.default.isAbsolute(home))
82
86
  throw new Error('Unresolved input references');
@@ -92,17 +96,20 @@ function activeReviewReferences() {
92
96
  }
93
97
  };
94
98
  for (const { bucket, record } of records) {
95
- if (record.recommendationInput && !complete(record))
96
- protect(record.recommendationInput.manifest, record.projectPath);
99
+ if (record.recommendationInput && !complete(record)) {
100
+ protect([...record.recommendationInput.manifest, ...(record.recommendationInput.artifacts || [])
101
+ .flatMap(artifact => artifact.path ? [{ locator: artifact.path }] : [])], record.projectPath);
102
+ }
97
103
  const state = record.reviewLifecycle;
98
104
  if (!state)
99
105
  continue;
100
- if (state.version !== 1)
106
+ if (state.version !== 1 && state.version !== 2)
101
107
  throw new Error('Unknown review version');
102
108
  for (const review of Object.values(state.reviewsByJobInstance)) {
103
109
  const job = state.jobs[review.jobInstanceId];
104
- if (!job || (0, review_lifecycle_1.projectReviewLifecycle)({ ...state, activeJob: job }).displayState !== 'complete')
105
- protect(review.manifest, state.source.projectPath);
110
+ if (!job || (0, review_lifecycle_1.projectReviewLifecycle)({ ...state, activeJob: job }).displayState !== 'complete') {
111
+ protect([...review.manifest, ...handoffReferences(review.handoff)], state.source.projectPath);
112
+ }
106
113
  }
107
114
  for (const operation of Object.values(state.operations)) {
108
115
  if (operation.kind !== 'recommendation')
@@ -111,9 +118,11 @@ function activeReviewReferences() {
111
118
  if (child && complete(child))
112
119
  continue;
113
120
  const review = state.reviewsByJobInstance[operation.jobInstanceId];
114
- const manifest = operation.input?.manifest || (review?.revisionId === operation.revisionId
115
- ? review.manifest : review?.history.find(previous => previous.revisionId === operation.revisionId)?.manifest);
116
- protect(manifest, state.source.projectPath);
121
+ const submitted = review?.revisionId === operation.revisionId ? review
122
+ : review?.history.find(previous => previous.revisionId === operation.revisionId);
123
+ const manifest = operation.input?.manifest || submitted?.manifest;
124
+ protect(manifest && [...manifest, ...(operation.input?.artifacts || []).flatMap(artifact => artifact.path ? [{ locator: artifact.path }] : []),
125
+ ...handoffReferences(submitted?.handoff)], state.source.projectPath);
117
126
  }
118
127
  }
119
128
  return { paths, unknown: false };
@@ -2903,7 +2903,8 @@ class FraimLocalMCPServer {
2903
2903
  });
2904
2904
  // Submission candidates are optional; the Hub filters unavailable jobs.
2905
2905
  // A malformed suggestion must not reject an otherwise valid review.
2906
- if (!args.currentPhase.includes('submit') && !args.currentPhase.includes('submission')) {
2906
+ if (args.currentPhase !== 'retrospective' && !(0, handoff_contracts_1.isSubmitPhase)(args.currentPhase, args.status, handoffPhaseMap)
2907
+ && !args.currentPhase.includes('submit') && !args.currentPhase.includes('submission')) {
2907
2908
  handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
2908
2909
  }
2909
2910
  // Issue #1857: enforced on its own terms rather than folded into
@@ -2919,8 +2920,9 @@ class FraimLocalMCPServer {
2919
2920
  if (handoffErrors.length > 0) {
2920
2921
  const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
2921
2922
  : handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
2922
- : handoffErrors[0].includes('evidence.approved') ? 'approved'
2923
- : 'delegationLedger';
2923
+ : handoffErrors[0].includes('finalizationOutcome') ? 'finalizationOutcome'
2924
+ : handoffErrors[0].includes('evidence.approved') ? 'approved'
2925
+ : 'delegationLedger';
2924
2926
  // Content-scoped applicability (issue #1318 follow-up on #1276): not every
2925
2927
  // job's submission phase promises evidence.reviewHandoff. Jobs built on
2926
2928
  // rich-review-artifact-contract.md, author-docx.md, or reporting-standards.md
@@ -3146,12 +3148,6 @@ class FraimLocalMCPServer {
3146
3148
  }
3147
3149
  processedResponse.result.structuredContent = { ...processedResponse.result.structuredContent, ...(acceptedReceipt ? { acceptedReceipt } : {}) };
3148
3150
  }
3149
- else if (process.env.FRAIM_REVIEW_BINDING && !(args.status === 'starting' && args.requestInstructions === true)) {
3150
- const binding = (0, run_recovery_state_1.parseReviewExecutionBinding)(process.env.FRAIM_REVIEW_BINDING);
3151
- if (binding?.revisionId && binding.jobInstanceId === args.jobId) {
3152
- return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: 'Remote phase acceptance lacks a supported workflow receipt.' }] } };
3153
- }
3154
- }
3155
3151
  const textBlock = processedResponse.result?.content?.find((item) => item.type === 'text');
3156
3152
  if (textBlock) {
3157
3153
  const remoteWarning = metadata.phaseStartWarning;
@@ -369,6 +369,18 @@ Do not use this tool for other repositories or external project issue trackers.`
369
369
  },
370
370
  evidence: {
371
371
  type: 'object',
372
+ properties: {
373
+ finalizationOutcome: {
374
+ type: 'object',
375
+ description: 'Explicit outcome of the whole approved assignment after selected work and required closure. Report success with phase status complete, or failure/incomplete with the matching phase status. Ordinary phase completion is not an assignment outcome. No job/run identifiers or receipt are required.',
376
+ properties: {
377
+ status: { type: 'string', enum: ['success', 'failure', 'incomplete'] },
378
+ summary: { type: 'string', minLength: 1, description: 'Actual result, or the reason work failed or remains incomplete.' }
379
+ },
380
+ required: ['status', 'summary'],
381
+ additionalProperties: false
382
+ }
383
+ },
372
384
  description: 'Structured evidence or data collected (e.g., prospect counts, test results). Submit phases should put review artifacts in evidence.reviewHandoff rather than printing raw JSON to the user. Prepare optional follow-on candidates at submission in evidence.nextJobRecommendations (0-3 entries with jobId, label, reason?, contextSummary?, artifactIds?: string[], prerequisites?: {kind, reference}[]); Standalone jobs with an explicit recommendation step may emit there; the Hub retains submitted candidates through finalization. Prerequisite kinds: approved_artifact, delivered_artifact, selected_action_success, required_child_success, external_result. Reference actual artifacts/actions/children/provider evidence. Do not emit readiness or server-owned candidate/revision/source/start identities. Candidates are revealed only after persisted approval; selecting one prepares a draft, and only explicit Send starts a follow-on.',
373
385
  additionalProperties: true
374
386
  },
@@ -759,8 +759,9 @@ class McpService {
759
759
  if (handoffErrors.length > 0) {
760
760
  const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
761
761
  : handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
762
- : handoffErrors[0].includes('evidence.approved') ? 'approved'
763
- : 'delegationLedger';
762
+ : handoffErrors[0].includes('finalizationOutcome') ? 'finalizationOutcome'
763
+ : handoffErrors[0].includes('evidence.approved') ? 'approved'
764
+ : 'delegationLedger';
764
765
  // Same content-scoped applicability as the local proxy: a job whose
765
766
  // submission phase never promises evidence.reviewHandoff is not
766
767
  // gated on it here either.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.362-next.0",
3
+ "version": "2.0.362-next.3",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {