fraim 2.0.279 → 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 (submit phase, status === "awaiting_mentor")
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. The canonical signal is status === "awaiting_mentor"
35
- * — set only by submit-phase jobs requesting a review gate — rather than a
36
- * hardcoded phase name list, which is fragile for personalized jobs.
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
- return status === 'awaiting_mentor';
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
- if (isSubmitPhase(phase, status)) {
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
- : 'delegationLedger';
2222
- this.log(`⚠️ Handoff contract enforcement rejected seekMentoring for ${args.jobName}:${args.currentPhase}: ${handoffErrors.join('; ')}`);
2223
- const rejection = (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, missingField, handoffErrors);
2224
- return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.279",
3
+ "version": "2.0.281",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {