fraim 2.0.280 → 2.0.281
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.
|
@@ -230,6 +230,41 @@ class AIMentor {
|
|
|
230
230
|
status
|
|
231
231
|
};
|
|
232
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* The job's declared phase graph (onSuccess/onFailure edges), or undefined
|
|
235
|
+
* for a simple/bootstrap-style job with no phases. Lets a caller derive
|
|
236
|
+
* graph facts (e.g. "which phase is this job's submission phase", issue
|
|
237
|
+
* #1276) without hardcoding phase names or duplicating job loading.
|
|
238
|
+
*/
|
|
239
|
+
async getJobPhaseMap(jobName) {
|
|
240
|
+
const job = await this.getOrLoadJob(jobName);
|
|
241
|
+
return job?.metadata.phases;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* True when a job's phase, with its includes resolved, itself instructs
|
|
245
|
+
* the agent to emit `evidence.reviewHandoff` (issue #1318 follow-up).
|
|
246
|
+
*
|
|
247
|
+
* The #1276 reviewHandoff gate fires for any job's graph-derived
|
|
248
|
+
* submission phase, but not every job family promises reviewHandoff at
|
|
249
|
+
* that phase — jobs built on `rich-review-artifact-contract.md`,
|
|
250
|
+
* `author-docx.md`, or `reporting-standards.md` (analysis reports, DOCX
|
|
251
|
+
* deliverables, operational setup jobs) use a different, legitimate
|
|
252
|
+
* completion contract and never mention the field, even resolved. Gating
|
|
253
|
+
* those unconditionally would reject a call the job's own instructions
|
|
254
|
+
* never told the agent to satisfy. This check reads the phase's actual
|
|
255
|
+
* resolved instructions rather than a hardcoded skill/job allowlist, so
|
|
256
|
+
* a new reviewHandoff-emitting skill (or a new non-reviewHandoff one)
|
|
257
|
+
* needs no update here — same "derive, don't hardcode" reasoning as
|
|
258
|
+
* `derivePredecessorPhase`.
|
|
259
|
+
*/
|
|
260
|
+
async phasePromisesReviewHandoff(jobName, phaseId) {
|
|
261
|
+
const job = await this.getOrLoadJob(jobName);
|
|
262
|
+
const raw = job?.phases.get(phaseId);
|
|
263
|
+
if (!raw)
|
|
264
|
+
return false;
|
|
265
|
+
const resolved = await this.resolveIncludes(raw, job.path);
|
|
266
|
+
return resolved.includes('evidence.reviewHandoff');
|
|
267
|
+
}
|
|
233
268
|
async getJobOverview(jobName) {
|
|
234
269
|
const job = await this.getOrLoadJob(jobName);
|
|
235
270
|
if (!job)
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Pure helpers for validating the three evidence fields that FRAIM jobs must
|
|
6
6
|
* emit on key seekMentoring calls so the Hub can render its review surfaces:
|
|
7
7
|
*
|
|
8
|
-
* - evidence.reviewHandoff (
|
|
8
|
+
* - evidence.reviewHandoff (job's submission phase, status === "complete";
|
|
9
|
+
* see isSubmitPhase for how that phase is found)
|
|
9
10
|
* - evidence.nextJobRecommendations (retrospective phase completion)
|
|
10
11
|
* - evidence.delegationLedger (create-delegation-graph phase)
|
|
11
12
|
*
|
|
@@ -26,17 +27,36 @@ exports.validateNextJobRecommendations = validateNextJobRecommendations;
|
|
|
26
27
|
exports.validateDelegationLedger = validateDelegationLedger;
|
|
27
28
|
exports.validateHandoffContracts = validateHandoffContracts;
|
|
28
29
|
exports.buildHandoffRejectionMessage = buildHandoffRejectionMessage;
|
|
30
|
+
const resolve_phase_edge_1 = require("./resolve-phase-edge");
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
30
32
|
// Phase detection
|
|
31
33
|
// ---------------------------------------------------------------------------
|
|
32
34
|
/**
|
|
33
35
|
* Returns true when a seekMentoring call is a submit-phase handoff requiring
|
|
34
|
-
* a reviewHandoff evidence field.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
36
|
+
* a reviewHandoff evidence field.
|
|
37
|
+
*
|
|
38
|
+
* Two triggers, either is sufficient:
|
|
39
|
+
* - `status === "awaiting_mentor"`: the original #916 signal. No published
|
|
40
|
+
* `seekMentoring` status permits this value today (confirmed by
|
|
41
|
+
* `spike/1157-approval-gate/s5-dead-submit-gate.js`), so this branch is
|
|
42
|
+
* provably unreachable in production — kept so a future status value, or a
|
|
43
|
+
* job that still emits it, is not silently unenforced.
|
|
44
|
+
* - `status === "complete"` at the job's own submission phase, derived from
|
|
45
|
+
* its real phase graph (issue #1276, gating-only rebuild of #1157 Change
|
|
46
|
+
* 1): the unique phase whose `onSuccess` targets `address-feedback`. This
|
|
47
|
+
* is the trigger that actually fires. No hardcoded phase-name list — a
|
|
48
|
+
* personalized job with a differently-named submission phase is still
|
|
49
|
+
* caught, and a job whose graph doesn't uniquely name one (`phases`
|
|
50
|
+
* omitted, or the derivation is ambiguous) is not gated at all rather than
|
|
51
|
+
* guessed at.
|
|
37
52
|
*/
|
|
38
|
-
function isSubmitPhase(currentPhase, status) {
|
|
39
|
-
|
|
53
|
+
function isSubmitPhase(currentPhase, status, phases) {
|
|
54
|
+
if (status === 'awaiting_mentor')
|
|
55
|
+
return true;
|
|
56
|
+
if (status === 'complete' && phases) {
|
|
57
|
+
return (0, resolve_phase_edge_1.derivePredecessorPhase)(phases, resolve_phase_edge_1.FEEDBACK_PHASE_ID) === currentPhase;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
40
60
|
}
|
|
41
61
|
/**
|
|
42
62
|
* Returns true when a seekMentoring call is a retrospective completion.
|
|
@@ -221,7 +241,14 @@ function validateHandoffContracts(args) {
|
|
|
221
241
|
const phase = args.currentPhase ?? '';
|
|
222
242
|
const status = args.status ?? '';
|
|
223
243
|
const evidence = args.evidence ?? {};
|
|
224
|
-
|
|
244
|
+
const findings = args.findings ?? {};
|
|
245
|
+
if (isSubmitPhase(phase, status, args.phases) && !evidence.reviewHandoff && findings.reviewHandoff) {
|
|
246
|
+
return ['reviewHandoff was found in findings but must be nested under evidence. Move reviewHandoff out of findings and into the top-level evidence object.'];
|
|
247
|
+
}
|
|
248
|
+
if (isRetrospectivePhase(phase, status) && !evidence.nextJobRecommendations && findings.nextJobRecommendations) {
|
|
249
|
+
return ['nextJobRecommendations was found in findings but must be nested under evidence. Move nextJobRecommendations out of findings and into the top-level evidence object.'];
|
|
250
|
+
}
|
|
251
|
+
if (isSubmitPhase(phase, status, args.phases)) {
|
|
225
252
|
const errors = validateReviewHandoff(evidence.reviewHandoff);
|
|
226
253
|
if (errors)
|
|
227
254
|
return errors;
|
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
* change inert for every job that authors no map.
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.FEEDBACK_PHASE_ID = void 0;
|
|
18
19
|
exports.resolvePhaseEdge = resolvePhaseEdge;
|
|
19
20
|
exports.resolveDiscriminant = resolveDiscriminant;
|
|
20
21
|
exports.discriminantKeys = discriminantKeys;
|
|
22
|
+
exports.derivePredecessorPhase = derivePredecessorPhase;
|
|
21
23
|
/** The default discriminant, and the mandatory key on every authored map. */
|
|
22
24
|
const DEFAULT_DISCRIMINANT = 'default';
|
|
23
25
|
/**
|
|
@@ -73,3 +75,34 @@ function discriminantKeys(edge) {
|
|
|
73
75
|
return [];
|
|
74
76
|
return Object.keys(edge).filter((key) => key !== DEFAULT_DISCRIMINANT);
|
|
75
77
|
}
|
|
78
|
+
/** The framework's one review/decision phase; every reviewable job routes into it. */
|
|
79
|
+
exports.FEEDBACK_PHASE_ID = 'address-feedback';
|
|
80
|
+
/**
|
|
81
|
+
* Finds the unique phase in a job's phase map whose `onSuccess` edge can
|
|
82
|
+
* resolve to `targetPhaseId` for some discriminant (including `default`).
|
|
83
|
+
*
|
|
84
|
+
* Gating-only rebuild of #1157 Change 1 ("graph-derived phase identity"),
|
|
85
|
+
* scoped per #1276: that change also added routing behavior and was reverted
|
|
86
|
+
* before merge because the two were bundled. Only the lookup survives here —
|
|
87
|
+
* it answers "which phase is this job's submission phase" without changing
|
|
88
|
+
* how any phase transition resolves. No hardcoded phase-name list: the
|
|
89
|
+
* derivation reads whatever the job's own graph declares, so it is correct
|
|
90
|
+
* for every current and future job without per-job edits.
|
|
91
|
+
*
|
|
92
|
+
* Returns `null` when zero or multiple phases match, so a caller can fail
|
|
93
|
+
* safe (treat as "unknown") rather than guess at an ambiguous graph.
|
|
94
|
+
*/
|
|
95
|
+
function derivePredecessorPhase(phases, targetPhaseId) {
|
|
96
|
+
const predecessors = [];
|
|
97
|
+
for (const [phaseId, edges] of Object.entries(phases ?? {})) {
|
|
98
|
+
if (phaseId === targetPhaseId || !edges)
|
|
99
|
+
continue;
|
|
100
|
+
const edge = edges.onSuccess;
|
|
101
|
+
const targets = typeof edge === 'string'
|
|
102
|
+
? [edge]
|
|
103
|
+
: (edge && typeof edge === 'object' ? Object.values(edge) : []);
|
|
104
|
+
if (targets.includes(targetPhaseId))
|
|
105
|
+
predecessors.push(phaseId);
|
|
106
|
+
}
|
|
107
|
+
return predecessors.length === 1 ? predecessors[0] : null;
|
|
108
|
+
}
|
|
@@ -2209,19 +2209,58 @@ class FraimLocalMCPServer {
|
|
|
2209
2209
|
// nextJobRecommendations, and delegationLedger evidence fields.
|
|
2210
2210
|
// The Hub cannot render review bars, next-job chips, or the
|
|
2211
2211
|
// delegation board when these fields are absent.
|
|
2212
|
+
const handoffPhaseMap = await mentor.getJobPhaseMap(args.jobName);
|
|
2212
2213
|
const handoffErrors = (0, handoff_contracts_1.validateHandoffContracts)({
|
|
2213
2214
|
jobName: args.jobName,
|
|
2214
2215
|
currentPhase: args.currentPhase,
|
|
2215
2216
|
status: args.status,
|
|
2216
2217
|
evidence: args.evidence,
|
|
2218
|
+
findings: args.findings,
|
|
2219
|
+
phases: handoffPhaseMap,
|
|
2217
2220
|
});
|
|
2218
2221
|
if (handoffErrors.length > 0) {
|
|
2219
2222
|
const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
|
|
2220
2223
|
: handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
|
|
2221
|
-
: '
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2224
|
+
: handoffErrors[0].includes('evidence.approved') ? 'approved'
|
|
2225
|
+
: 'delegationLedger';
|
|
2226
|
+
// Content-scoped applicability (issue #1318 follow-up on #1276): not every
|
|
2227
|
+
// job's submission phase promises evidence.reviewHandoff. Jobs built on
|
|
2228
|
+
// rich-review-artifact-contract.md, author-docx.md, or reporting-standards.md
|
|
2229
|
+
// (analysis reports, DOCX deliverables, operational setup jobs) use a
|
|
2230
|
+
// different, legitimate completion contract and never instruct the field.
|
|
2231
|
+
// Enforcing the gate on them would reject a call their own phase text never
|
|
2232
|
+
// told the agent to satisfy — a genuine stuck-agent risk, not a hypothetical
|
|
2233
|
+
// one (grep-verified against the registry: ~16 of 183 reviewable jobs). This
|
|
2234
|
+
// is not a violation at all when the phase never promised the field, so it is
|
|
2235
|
+
// skipped entirely rather than logged. Escape hatch for the promised case:
|
|
2236
|
+
// FRAIM_HANDOFF_ENFORCEMENT_MODE=off disables rejection (still logs);
|
|
2237
|
+
// =warn logs without rejecting; default is unconditional rejection, matching
|
|
2238
|
+
// how the other three handoff contracts already behave.
|
|
2239
|
+
const isReviewHandoffField = missingField === 'reviewHandoff';
|
|
2240
|
+
const reviewHandoffApplies = !isReviewHandoffField
|
|
2241
|
+
|| await mentor.phasePromisesReviewHandoff(args.jobName, args.currentPhase);
|
|
2242
|
+
// Escape hatch scoped to reviewHandoff only: the other three contracts
|
|
2243
|
+
// (nextJobRecommendations, delegationLedger, approved) have shipped
|
|
2244
|
+
// unconditional, uncapped enforcement since #916/#1157 with no reported
|
|
2245
|
+
// issue, so they keep that behavior — FRAIM_HANDOFF_ENFORCEMENT_MODE never
|
|
2246
|
+
// relaxes them.
|
|
2247
|
+
const enforcementMode = isReviewHandoffField
|
|
2248
|
+
? (process.env.FRAIM_HANDOFF_ENFORCEMENT_MODE || 'enforce')
|
|
2249
|
+
: 'enforce';
|
|
2250
|
+
if (!reviewHandoffApplies) {
|
|
2251
|
+
// Not gated: this job's submission phase never promised reviewHandoff.
|
|
2252
|
+
}
|
|
2253
|
+
else if (enforcementMode === 'off') {
|
|
2254
|
+
this.log(`⚠️ [reviewHandoff enforcement: off] seekMentoring for ${args.jobName}:${args.currentPhase} would be rejected: ${handoffErrors.join('; ')}`);
|
|
2255
|
+
}
|
|
2256
|
+
else if (enforcementMode === 'warn') {
|
|
2257
|
+
this.log(`⚠️ [reviewHandoff enforcement: warn-only] seekMentoring for ${args.jobName}:${args.currentPhase} would be rejected: ${handoffErrors.join('; ')}`);
|
|
2258
|
+
}
|
|
2259
|
+
else {
|
|
2260
|
+
this.log(`⚠️ Handoff contract enforcement rejected seekMentoring for ${args.jobName}:${args.currentPhase}: ${handoffErrors.join('; ')}`);
|
|
2261
|
+
const rejection = (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, missingField, handoffErrors);
|
|
2262
|
+
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
|
|
2263
|
+
}
|
|
2225
2264
|
}
|
|
2226
2265
|
return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
2227
2266
|
}
|