fraim 2.0.362-next.2 → 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.
- package/dist/src/config/persona-capability-bundles.js +1 -1
- package/dist/src/core/handoff-contracts.js +32 -13
- package/dist/src/core/review-lifecycle.js +106 -91
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +18 -9
- package/dist/src/local-mcp-server/stdio-server.js +5 -9
- package/dist/src/mcp/tool-schemas.js +12 -0
- package/dist/src/services/mcp-service.js +3 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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 (
|
|
614
|
-
|
|
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
|
-
|
|
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.
|
|
12
|
-
exports.
|
|
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:
|
|
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
|
|
30
|
+
return state && (state.version === 1 || state.version === 2) && state.activeJob
|
|
31
|
+
? state.reviewsByJobInstance[state.activeJob.jobInstanceId] : undefined;
|
|
31
32
|
}
|
|
32
|
-
function
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
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 (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
123
|
-
return
|
|
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:
|
|
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
|
|
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.
|
|
146
|
-
return { ...view, displayState: '
|
|
147
|
-
if (review.
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
177
|
-
return { readiness: '
|
|
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
|
|
182
|
-
const
|
|
183
|
-
const met = p.kind === 'approved_artifact' ?
|
|
184
|
-
: p.kind === 'delivered_artifact' ?
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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.
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
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 (
|
|
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('
|
|
2923
|
-
: '
|
|
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('
|
|
763
|
-
: '
|
|
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.
|