fraim 2.0.306 → 2.0.308

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.
@@ -179,8 +179,8 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
179
179
  sam: {
180
180
  personaKey: 'sam',
181
181
  bundleId: 'persona-sam-core',
182
- catalogMetadata: buildCatalogMetadata('sam', ['crm-pipeline-review', 'outbound-sales-strategy', 'discovery-coaching']),
183
- protectedJobs: ['crm-pipeline-review', 'outbound-sales-strategy', 'discovery-coaching', 'deal-strategy', 'proposal-development', 'account-strategy', 'sales-coaching'],
182
+ catalogMetadata: buildCatalogMetadata('sam', ['crm-pipeline-review', 'outbound-sales-strategy', 'sales-discovery-preparation']),
183
+ protectedJobs: ['crm-pipeline-review', 'outbound-sales-strategy', 'sales-discovery-preparation', 'deal-strategy', 'proposal-development', 'account-strategy', 'sales-coaching'],
184
184
  protectedAliases: ['sales', 'sales-manager', 'account-manager', 'pipeline'],
185
185
  defaultHireMode: 'job',
186
186
  lockCopy: 'Hire SAM to unlock full-cycle sales work for this request.'
@@ -17,11 +17,16 @@
17
17
  * after the existing quality enforcement block.
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.PHASE_START_WARN_PHASES = void 0;
20
21
  exports.isSubmitPhase = isSubmitPhase;
21
22
  exports.isRetrospectivePhase = isRetrospectivePhase;
22
23
  exports.isAddressFeedbackCompletion = isAddressFeedbackCompletion;
23
24
  exports.isDelegationGraphPhase = isDelegationGraphPhase;
24
25
  exports.validateAddressFeedbackApproval = validateAddressFeedbackApproval;
26
+ exports.validateReviewActionCompleteness = validateReviewActionCompleteness;
27
+ exports.hasPhaseStarted = hasPhaseStarted;
28
+ exports.upsertPhaseStarted = upsertPhaseStarted;
29
+ exports.buildPhaseStartWarning = buildPhaseStartWarning;
25
30
  exports.validateReviewHandoff = validateReviewHandoff;
26
31
  exports.validateNextJobRecommendations = validateNextJobRecommendations;
27
32
  exports.validateDelegationLedger = validateDelegationLedger;
@@ -96,6 +101,123 @@ function validateAddressFeedbackApproval(evidence) {
96
101
  }
97
102
  return null;
98
103
  }
104
+ // ---------------------------------------------------------------------------
105
+ // Issue #1590 — shape enforcement for review-action completeness
106
+ //
107
+ // #916/#1463 (above) validate that a reviewHandoff payload is internally
108
+ // well-formed (an enum'd reviewTarget.type, pull_request <=> empty artifacts,
109
+ // well-shaped artifact_set entries). It does not check whether the claimed
110
+ // pull_request target's reviewActions are actually complete enough to be
111
+ // useful given the repository state it describes — a structurally valid
112
+ // reviewActions array can still leave out the specific provider action the
113
+ // manager needs (issue #1541: a PR handoff shipped only the generic
114
+ // approve/request_changes pair, with no merge action of any kind). This
115
+ // validator is pure: the proxy (src/local-mcp-server/stdio-server.ts) does
116
+ // the I/O (git branch state) and passes plain data in.
117
+ //
118
+ // (Choosing artifact_set vs. pull_request for the review target itself is
119
+ // not mechanized: registry/delivery/submit.md's own Contract rules already
120
+ // state that rule in prose, and the fix for a model missing it is a
121
+ // tighter, more direct instruction plus eval coverage proving compliance,
122
+ // not a second code path re-deriving the same fact from job content.)
123
+ // ---------------------------------------------------------------------------
124
+ /** A composed `deliveryActionId` accepts a bare id or a `<domain>+<id>` suffix (registry/providers/delivery-pr.json). */
125
+ function deliveryActionIdMatches(actions, id) {
126
+ return actions.some((a) => {
127
+ const deliveryActionId = a.deliveryActionId;
128
+ return typeof deliveryActionId === 'string'
129
+ && (deliveryActionId === id || deliveryActionId.endsWith(`+${id}`));
130
+ });
131
+ }
132
+ /**
133
+ * Review-action-completeness.
134
+ *
135
+ * Only applies to a `pull_request` reviewTarget with a configured repository
136
+ * (repo: null means no repository is configured, e.g. a non-repo-backed
137
+ * job — gate does not apply). A self-consistency check against the proxy's
138
+ * own locally-observed git branch state (`detectRepoInfo()`), not a live
139
+ * GitHub/GitLab API call: it does not confirm the PR is actually open on the
140
+ * provider, only that the claimed pull_request target is backed by the
141
+ * provider actions local branch state implies it should carry.
142
+ *
143
+ * - currentBranch === defaultBranch: requires a `push_default_branch`
144
+ * deliveryActionId (bare or composed `<domain>+push_default_branch`).
145
+ * - Otherwise (feature branch, i.e. an open-PR review claimed): requires
146
+ * both `merge_pr` and `merge_pr_work_completion` (bare or composed).
147
+ */
148
+ function validateReviewActionCompleteness(reviewTarget, reviewActions, repo) {
149
+ if (!reviewTarget || reviewTarget.type !== 'pull_request')
150
+ return null;
151
+ if (!repo)
152
+ return null;
153
+ const actions = Array.isArray(reviewActions) ? reviewActions : [];
154
+ const errors = [];
155
+ if (repo.currentBranch === repo.defaultBranch) {
156
+ if (!deliveryActionIdMatches(actions, 'push_default_branch')) {
157
+ errors.push(`evidence.reviewHandoff.reviewActions must include an approve_push_default_branch action (an ` +
158
+ `approve_delivery entry whose deliveryActionId is "push_default_branch", or ends with ` +
159
+ `"+push_default_branch") — the current branch is the default branch ("${repo.defaultBranch}"), so ` +
160
+ `the manager needs a one-click way to push it.`);
161
+ }
162
+ }
163
+ else {
164
+ if (!deliveryActionIdMatches(actions, 'merge_pr')) {
165
+ errors.push(`evidence.reviewHandoff.reviewActions must include an approve_merge_pr action (an approve_delivery ` +
166
+ `entry whose deliveryActionId is "merge_pr", or ends with "+merge_pr") — an open pull request is ` +
167
+ `the claimed review target, so the manager needs a one-click way to merge it.`);
168
+ }
169
+ if (!deliveryActionIdMatches(actions, 'merge_pr_work_completion')) {
170
+ errors.push(`evidence.reviewHandoff.reviewActions must include an approve_merge_pr_work_completion action (an ` +
171
+ `approve_delivery entry whose deliveryActionId is "merge_pr_work_completion", or ends with ` +
172
+ `"+merge_pr_work_completion") — the manager also needs a one-click way to merge the PR and ` +
173
+ `complete the issue.`);
174
+ }
175
+ }
176
+ return errors.length > 0 ? errors : null;
177
+ }
178
+ /**
179
+ * True when `marker` records a "starting" timestamp for `phaseId`. Fails
180
+ * open (false, never throws) for a missing file (marker: undefined), a
181
+ * missing key, or a malformed shape — this is a warn-only signal, not a
182
+ * required artifact.
183
+ */
184
+ function hasPhaseStarted(marker, phaseId) {
185
+ if (!marker || typeof marker !== 'object' || Array.isArray(marker))
186
+ return false;
187
+ const startedPhases = marker.startedPhases;
188
+ if (!startedPhases || typeof startedPhases !== 'object' || Array.isArray(startedPhases))
189
+ return false;
190
+ const value = startedPhases[phaseId];
191
+ return typeof value === 'string' && value.length > 0;
192
+ }
193
+ /**
194
+ * Returns a new marker with `phaseId` upserted to `timestamp`, preserving
195
+ * every other phase already recorded. Tolerates a missing or malformed
196
+ * existing marker by starting from an empty `startedPhases`.
197
+ */
198
+ function upsertPhaseStarted(marker, phaseId, timestamp) {
199
+ const existing = marker && typeof marker === 'object' && !Array.isArray(marker)
200
+ ? marker.startedPhases
201
+ : undefined;
202
+ const startedPhases = existing && typeof existing === 'object' && !Array.isArray(existing)
203
+ ? { ...existing }
204
+ : {};
205
+ startedPhases[phaseId] = timestamp;
206
+ return { startedPhases };
207
+ }
208
+ /** The single phase this gate covers today (RFC: scoped, not universal — see Risk Assessment). */
209
+ exports.PHASE_START_WARN_PHASES = ['address-feedback'];
210
+ /**
211
+ * Builds the warning prepended to an otherwise-valid address-feedback
212
+ * response when this run never called seekMentoring(status: "starting") for
213
+ * that phase. Warn-only, never a rejection: a lost marker (disk cleanup, a
214
+ * first-ever run before this shipped) must not strand a legitimate resume.
215
+ */
216
+ function buildPhaseStartWarning(phaseId) {
217
+ return (`⚠️ **Phase-start notice**: this run did not call \`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` ` +
218
+ `before this call. If you resumed after context compaction, re-read \`registry/delivery/address-feedback.md\` now — ` +
219
+ `Step 4 (write the standalone feedback file) is easy to skip when acting from remembered context.\n\n`);
220
+ }
99
221
  function isAbsoluteHttpUrl(url) {
100
222
  if (typeof url !== 'string' || !url)
101
223
  return false;
@@ -354,9 +476,19 @@ function validateHandoffContracts(args) {
354
476
  return ['nextJobRecommendations was found in findings but must be nested under evidence. Move nextJobRecommendations out of findings and into the top-level evidence object.'];
355
477
  }
356
478
  if (isSubmitPhase(phase, status, args.phases)) {
357
- const errors = validateReviewHandoff(evidence.reviewHandoff);
358
- if (errors)
359
- return errors;
479
+ const structuralErrors = validateReviewHandoff(evidence.reviewHandoff);
480
+ if (structuralErrors)
481
+ return structuralErrors;
482
+ // Issue #1590: a structurally valid reviewHandoff can still leave out
483
+ // the specific provider action the claimed repository state requires
484
+ // (issue #1541). Shares reviewHandoff's FRAIM_HANDOFF_ENFORCEMENT_MODE
485
+ // escape hatch (see stdio-server.ts). No-ops when repo is absent.
486
+ const reviewHandoff = evidence.reviewHandoff;
487
+ const reviewTarget = reviewHandoff?.reviewTarget;
488
+ const reviewActions = reviewHandoff?.reviewActions;
489
+ const actionCompletenessErrors = validateReviewActionCompleteness(reviewTarget, reviewActions, args.repo ?? null);
490
+ if (actionCompletenessErrors)
491
+ return actionCompletenessErrors;
360
492
  }
361
493
  if (isRetrospectivePhase(phase, status)) {
362
494
  const errors = validateNextJobRecommendations(evidence.nextJobRecommendations);
@@ -1057,6 +1057,58 @@ class FraimLocalMCPServer {
1057
1057
  return null;
1058
1058
  return (0, path_1.join)(homeDir, '.fraim', 'cache', 'registry', 'providers', filename);
1059
1059
  }
1060
+ /**
1061
+ * Issue #1590, Gate 4 — path to the per-run phase-start marker.
1062
+ * ~/.fraim/run-state/{jobId}.json, keyed by the jobId seekMentoring already
1063
+ * carries on every call, so no new identifier or collision risk across the
1064
+ * user's concurrent multi-issue fleet.
1065
+ */
1066
+ getRunStateMarkerPath(jobId) {
1067
+ const homeDir = this.getHomeDir();
1068
+ if (!homeDir)
1069
+ return null;
1070
+ return (0, path_1.join)(homeDir, '.fraim', 'run-state', `${jobId}.json`);
1071
+ }
1072
+ /** Reads and parses the run-state marker, tolerating a missing/corrupt file (warn-only signal, never throws). */
1073
+ readRunStateMarker(jobId) {
1074
+ try {
1075
+ const markerPath = this.getRunStateMarkerPath(jobId);
1076
+ if (!markerPath || !(0, fs_1.existsSync)(markerPath))
1077
+ return undefined;
1078
+ return JSON.parse((0, fs_1.readFileSync)(markerPath, 'utf8'));
1079
+ }
1080
+ catch (error) {
1081
+ this.log(`⚠️ [phase-start] failed to read run-state marker for jobId=${jobId}: ${error.message}`);
1082
+ return undefined;
1083
+ }
1084
+ }
1085
+ /** Upserts one phase's started timestamp into the run-state marker. Best-effort: a write failure only degrades Gate 4 to always-warn for this run. */
1086
+ writeRunStateMarker(jobId, phaseId, timestamp) {
1087
+ try {
1088
+ const markerPath = this.getRunStateMarkerPath(jobId);
1089
+ if (!markerPath)
1090
+ return;
1091
+ const existing = this.readRunStateMarker(jobId);
1092
+ const updated = (0, handoff_contracts_1.upsertPhaseStarted)(existing, phaseId, timestamp);
1093
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(markerPath), { recursive: true });
1094
+ (0, fs_1.writeFileSync)(markerPath, JSON.stringify(updated), 'utf8');
1095
+ }
1096
+ catch (error) {
1097
+ this.log(`⚠️ [phase-start] failed to write run-state marker for jobId=${jobId}: ${error.message}`);
1098
+ }
1099
+ }
1100
+ /** Deletes the run-state marker at job completion so the directory does not grow unbounded across a user's history of completed jobs. */
1101
+ deleteRunStateMarker(jobId) {
1102
+ try {
1103
+ const markerPath = this.getRunStateMarkerPath(jobId);
1104
+ if (markerPath && (0, fs_1.existsSync)(markerPath)) {
1105
+ (0, fs_1.unlinkSync)(markerPath);
1106
+ }
1107
+ }
1108
+ catch (error) {
1109
+ this.log(`⚠️ [phase-start] failed to delete run-state marker for jobId=${jobId}: ${error.message}`);
1110
+ }
1111
+ }
1060
1112
  readCachedTemplateFile(filename) {
1061
1113
  try {
1062
1114
  const cachePath = this.getProviderCachePath(filename);
@@ -2238,6 +2290,29 @@ class FraimLocalMCPServer {
2238
2290
  nextPhase: tutoringResponse.nextPhase,
2239
2291
  jobId: args.jobId || requestSessionId // Use jobId from args, fallback to sessionId
2240
2292
  });
2293
+ // Phase-start marker (Issue #1590 — warn-only).
2294
+ //
2295
+ // Detects a resumed address-feedback turn that never called
2296
+ // seekMentoring(status: "starting") for this run: the mentoring
2297
+ // response cache above is in-memory, keyed by request.id, and does
2298
+ // not survive the process restart a Hub resume performs, so a
2299
+ // durable per-jobId marker on disk is the only signal that
2300
+ // survives it. Scoped to address-feedback only (PHASE_START_WARN_PHASES);
2301
+ // never rejects — a lost marker (disk cleanup, first-ever run
2302
+ // before this shipped) must not strand a legitimate resume.
2303
+ let phaseStartWarning = '';
2304
+ if (args.jobId && handoff_contracts_1.PHASE_START_WARN_PHASES.includes(args.currentPhase)) {
2305
+ if (args.status === 'starting') {
2306
+ this.writeRunStateMarker(args.jobId, args.currentPhase, new Date().toISOString());
2307
+ }
2308
+ else if (args.status === 'complete' || args.status === 'failure') {
2309
+ const marker = this.readRunStateMarker(args.jobId);
2310
+ if (!(0, handoff_contracts_1.hasPhaseStarted)(marker, args.currentPhase)) {
2311
+ this.log(`⚠️ [phase-start] ${args.currentPhase} entered without a starting call this run: jobId=${args.jobId}`);
2312
+ phaseStartWarning = (0, handoff_contracts_1.buildPhaseStartWarning)(args.currentPhase);
2313
+ }
2314
+ }
2315
+ }
2241
2316
  // Quality enforcement (Issue #251).
2242
2317
  //
2243
2318
  // The local proxy owns seekMentoring for personalized-job support.
@@ -2293,6 +2368,15 @@ class FraimLocalMCPServer {
2293
2368
  // The Hub cannot render review bars, next-job chips, or the
2294
2369
  // delegation board when these fields are absent.
2295
2370
  const handoffPhaseMap = await mentor.getJobPhaseMap(args.jobName);
2371
+ // Issue #1590: locally-observed git branch state for the
2372
+ // review-action-completeness check. A repository is "configured"
2373
+ // for this purpose only when a defaultBranch reference point
2374
+ // exists; without it the check cannot classify default-vs-feature
2375
+ // branch, so it must not apply (repo: null).
2376
+ const handoffRepoInfo = this.detectRepoInfo();
2377
+ const handoffRepoState = handoffRepoInfo?.defaultBranch
2378
+ ? { currentBranch: handoffRepoInfo.branch || '', defaultBranch: handoffRepoInfo.defaultBranch }
2379
+ : null;
2296
2380
  const handoffErrors = (0, handoff_contracts_1.validateHandoffContracts)({
2297
2381
  jobName: args.jobName,
2298
2382
  currentPhase: args.currentPhase,
@@ -2300,6 +2384,7 @@ class FraimLocalMCPServer {
2300
2384
  evidence: args.evidence,
2301
2385
  findings: args.findings,
2302
2386
  phases: handoffPhaseMap,
2387
+ repo: handoffRepoState,
2303
2388
  });
2304
2389
  handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
2305
2390
  if (handoffErrors.length > 0) {
@@ -2367,7 +2452,7 @@ class FraimLocalMCPServer {
2367
2452
  const rejection = (0, test_evidence_contract_1.buildTestEvidenceRejectionMessage)(args.currentPhase, testEvidenceErrors);
2368
2453
  return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, rejection);
2369
2454
  }
2370
- return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
2455
+ return await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, phaseStartWarning + tutoringResponse.message);
2371
2456
  }
2372
2457
  catch (error) {
2373
2458
  this.log(`⚠️ Local seekMentoring failed: ${error.message}. Falling back to remote.`);
@@ -2763,6 +2848,10 @@ class FraimLocalMCPServer {
2763
2848
  catch (err) {
2764
2849
  this.log(`📊 ⚠️ Job complete event failed (non-blocking): ${err.message}`);
2765
2850
  }
2851
+ // Issue #1590, Gate 4: clean up the per-run phase-start marker so
2852
+ // ~/.fraim/run-state/ does not grow unbounded across a user's
2853
+ // history of completed jobs.
2854
+ this.deleteRunStateMarker(args.jobId);
2766
2855
  }
2767
2856
  }
2768
2857
  catch (error) {
@@ -45,6 +45,7 @@ const semver = __importStar(require("semver"));
45
45
  const compat_1 = require("../config/compat");
46
46
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
47
47
  const quality_evidence_1 = require("../core/quality-evidence");
48
+ const handoff_contracts_1 = require("../core/handoff-contracts");
48
49
  const feature_flags_1 = require("../config/feature-flags");
49
50
  const persona_entitlement_service_1 = require("./persona-entitlement-service");
50
51
  exports.DEFAULT_LAUNCH_PHRASE_MAPPINGS = {
@@ -706,6 +707,47 @@ class McpService {
706
707
  evidence: args.evidence,
707
708
  findings: args.findings
708
709
  });
710
+ // Handoff contract enforcement (Issue #916/#1157/#1276) fall-through
711
+ // safety net, matching the quality-enforcement note just below.
712
+ //
713
+ // The local MCP proxy (stdio-server.ts) is the primary enforcement
714
+ // point for reviewHandoff/nextJobRecommendations/delegationLedger/
715
+ // approved, but that entire check lives inside one try/catch around
716
+ // the local seekMentoring handler: any exception there falls through
717
+ // to this remote path with no re-check, and any client that calls
718
+ // this endpoint directly (bypassing the local proxy) never goes
719
+ // through the local check at all (issue #1590 investigation). Only
720
+ // the structural/shape gates run here, not the git-branch-state Gate
721
+ // 2 check — this service has no local repository to inspect, and
722
+ // that gate already no-ops when `repo` is absent.
723
+ const handoffPhaseMap = await this.aiMentor.getJobPhaseMap(args.jobName);
724
+ const handoffErrors = (0, handoff_contracts_1.validateHandoffContracts)({
725
+ jobName: args.jobName,
726
+ currentPhase: args.currentPhase,
727
+ status: args.status,
728
+ evidence: args.evidence,
729
+ findings: args.findings,
730
+ phases: handoffPhaseMap
731
+ });
732
+ if (handoffErrors.length > 0) {
733
+ const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
734
+ : handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
735
+ : handoffErrors[0].includes('evidence.approved') ? 'approved'
736
+ : 'delegationLedger';
737
+ // Same content-scoped applicability as the local proxy: a job whose
738
+ // submission phase never promises evidence.reviewHandoff is not
739
+ // gated on it here either.
740
+ const reviewHandoffApplies = missingField !== 'reviewHandoff'
741
+ || await this.aiMentor.phasePromisesReviewHandoff(args.jobName, args.currentPhase);
742
+ const enforcementMode = missingField === 'reviewHandoff'
743
+ ? (process.env.FRAIM_HANDOFF_ENFORCEMENT_MODE || 'enforce')
744
+ : 'enforce';
745
+ if (reviewHandoffApplies && enforcementMode === 'enforce') {
746
+ return {
747
+ content: [{ type: 'text', text: (0, handoff_contracts_1.buildHandoffRejectionMessage)(args.currentPhase, missingField, handoffErrors) }]
748
+ };
749
+ }
750
+ }
709
751
  // Quality enforcement (Issue #251): quality-producing jobs MUST emit
710
752
  // a valid `evidence.quality` object on their final completion call.
711
753
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.306",
3
+ "version": "2.0.308",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {