fraim 2.0.297 → 2.0.299

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.
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getLayerBackend = getLayerBackend;
7
+ exports.isLayerMisconfigured = isLayerMisconfigured;
7
8
  exports.setLayerBackend = setLayerBackend;
8
9
  exports.readUserFraimConfig = readUserFraimConfig;
9
10
  exports.writeUserFraimConfig = writeUserFraimConfig;
@@ -29,6 +30,25 @@ function getLayerBackend(layer) {
29
30
  return getManagerStorageConfig();
30
31
  return null;
31
32
  }
33
+ /**
34
+ * Returns true when the layer has a config entry whose backend is recognized
35
+ * but the required pointer field is missing (git with no gitUrl, local-folder
36
+ * with no localPath). Callers use this to distinguish "not configured" from
37
+ * "misconfigured" so the UI can surface a warning instead of silently falling
38
+ * back to single-machine. (issue #1465, R3)
39
+ */
40
+ function isLayerMisconfigured(layer) {
41
+ const raw = layer === 'org'
42
+ ? readUserFraimConfig().organization
43
+ : readUserFraimConfig().managerStorage;
44
+ if (!raw || typeof raw !== 'object')
45
+ return false;
46
+ if (raw.backend === 'git' && !raw.gitUrl)
47
+ return true;
48
+ if (raw.backend === 'local-folder' && !raw.localPath)
49
+ return true;
50
+ return false;
51
+ }
32
52
  /** Write the backend config for a named layer (issue #834). */
33
53
  function setLayerBackend(layer, config) {
34
54
  if (layer === 'org') {
@@ -113,11 +113,20 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
113
113
  defaultHireMode: 'job',
114
114
  lockCopy: 'Hire SEChar to unlock security setup, review, and findings-command work for this request.'
115
115
  },
116
+ travis: {
117
+ personaKey: 'travis',
118
+ bundleId: 'persona-travis-core',
119
+ catalogMetadata: buildCatalogMetadata('travis', ['travel-planning', 'travel-disruption-rebooking', 'travel-fare-watch']),
120
+ protectedJobs: ['travel-planning', 'travel-disruption-rebooking', 'travel-fare-watch'],
121
+ protectedAliases: ['travel-agent', 'travel', 'trip-planning', 'rebooking', 'fare-watch', 'flight-watch'],
122
+ defaultHireMode: 'job',
123
+ lockCopy: 'Hire TRAVis to unlock trip planning, fare watching, travel disruption handling, and rebooking support for this request.'
124
+ },
116
125
  ashley: {
117
126
  personaKey: 'ashley',
118
127
  bundleId: 'persona-ashley-core',
119
128
  catalogMetadata: buildCatalogMetadata('ashley', ['chief-of-staff-briefing', 'executive-assistant', 'analyze-transcript']),
120
- protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'analyze-transcript', 'travel-planning', 'travel-disruption-rebooking'],
129
+ protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'analyze-transcript'],
121
130
  protectedAliases: ['executive-assistant', 'operations-assistant'],
122
131
  defaultHireMode: 'job',
123
132
  lockCopy: 'Hire AshLey to unlock executive-assistant work for this request.'
@@ -199,6 +199,15 @@ exports.PERSONA_HIRE_CATALOG = {
199
199
  jobPriceCents: 890,
200
200
  fulltimePriceCents: 4990,
201
201
  },
202
+ travis: {
203
+ displayName: 'TRAVis',
204
+ role: 'Travel Agent',
205
+ emoji: '\u{1F9ED}',
206
+ gradient: 'linear-gradient(135deg, #0f766e 0%, #0ea5e9 100%)',
207
+ blurb: 'Plans trips, watches fares, handles travel disruptions, and helps with rebooking options while keeping provider limits and booking boundaries clear.',
208
+ jobPriceCents: 890,
209
+ fulltimePriceCents: 4990,
210
+ },
202
211
  ashley: {
203
212
  displayName: 'AshLey',
204
213
  role: 'Executive Assistant',
@@ -251,6 +260,7 @@ exports.PERSONA_AVATAR_CATALOG = {
251
260
  ricardo: { seed: 'RICARDO-recruiter', bg: 'ede9fe', style: 'notionists' },
252
261
  hari: { seed: 'HARI-hr', bg: 'ccfbf1', style: 'notionists' },
253
262
  careena: { seed: 'CAREENA-career-coach', bg: 'e0f2fe', style: 'notionists' },
263
+ travis: { seed: 'TRAVIS-travel-agent', bg: 'ccfbf1', style: 'notionists' },
254
264
  sade: { seed: 'SADE-salesforce', bg: 'bae6fd', style: 'notionists' },
255
265
  sam: { seed: 'SAM-sales-account', bg: 'd1fae5', style: 'notionists' },
256
266
  casey: { seed: 'CASEY-cx', bg: 'a5f3fc', style: 'notionists' },
@@ -83,7 +83,7 @@ class AIMentor {
83
83
  const validOutcomes = (0, resolve_phase_edge_1.discriminantKeys)(onSuccess)
84
84
  .map((key) => `"${key}"`)
85
85
  .join(' | ');
86
- return `\n\n---\n\n## Report Back\nWhen this phase is done, call \`seekMentoring({ jobName: "${jobName}", issueNumber: "<issue_number>", currentPhase: "${phaseId}", status: "complete", findings: { issueType: "<outcome>" } })\` with one of: ${validOutcomes}.${failureNote}`;
86
+ return `\n\n---\n\n## Report Back\nWhen this phase is done, call \`seekMentoring({ jobName: "${jobName}", issueNumber: "<issue_number>", currentPhase: "${phaseId}", status: "complete", findings: { phaseOutcome: "<outcome>" } })\` with one of: ${validOutcomes}.${failureNote}`;
87
87
  }
88
88
  /** Phase-authority content injected for all phased workflows. Loaded from orchestration/phase-authority.md. */
89
89
  async getPhaseAuthorityContent() {
@@ -173,7 +173,14 @@ class AIMentor {
173
173
  nextPhaseId = (0, resolve_phase_edge_1.resolvePhaseEdge)(phaseFlow.onSuccess, (0, resolve_phase_edge_1.resolveDiscriminant)(findings, evidence));
174
174
  }
175
175
  let message = '';
176
- if (nextPhaseId) {
176
+ if (nextPhaseId === resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION) {
177
+ message += [
178
+ '### Waiting For Next Invocation',
179
+ '',
180
+ 'Send a concise status update that this recurring assignment is waiting for its next scheduled check, then stop.'
181
+ ].join('\n');
182
+ }
183
+ else if (nextPhaseId) {
177
184
  message += `Moving to the next phase: **${nextPhaseId}**.\n\n`;
178
185
  let nextInstructions = workflow.phases.get(nextPhaseId);
179
186
  if (nextInstructions) {
@@ -96,6 +96,42 @@ function validateAddressFeedbackApproval(evidence) {
96
96
  }
97
97
  return null;
98
98
  }
99
+ function isAbsoluteHttpUrl(url) {
100
+ if (typeof url !== 'string' || !url)
101
+ return false;
102
+ try {
103
+ const parsed = new URL(url);
104
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ function validateReviewArtifactEntry(artifact, index) {
111
+ const errors = [];
112
+ const label = `evidence.reviewHandoff.artifacts[${index}]`;
113
+ if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
114
+ errors.push(`${label} must be an object`);
115
+ return errors;
116
+ }
117
+ const entry = artifact;
118
+ if ('kind' in entry && !('type' in entry)) {
119
+ errors.push(`${label} uses kind but must use type — use type, not kind for artifact type (e.g. "markdown", "docx", "source_file")`);
120
+ }
121
+ else if (!entry.type || typeof entry.type !== 'string' || !entry.type.trim()) {
122
+ errors.push(`${label}.type must be a non-empty string (e.g. "markdown", "docx", "source_file")`);
123
+ }
124
+ if (!entry.label || typeof entry.label !== 'string' || !entry.label.trim()) {
125
+ errors.push(`${label}.label must be a non-empty string`);
126
+ }
127
+ if ('url' in entry) {
128
+ errors.push(`${label} must not include url — artifact_set artifacts are local files; use path`);
129
+ }
130
+ if (!entry.path || typeof entry.path !== 'string' || !entry.path.trim()) {
131
+ errors.push(`${label}.path must be a non-empty local path`);
132
+ }
133
+ return errors;
134
+ }
99
135
  /**
100
136
  * Validates evidence.reviewHandoff. Returns null if valid, or an array of
101
137
  * human-readable error strings describing what is wrong.
@@ -103,9 +139,16 @@ function validateAddressFeedbackApproval(evidence) {
103
139
  * Required minimum shape:
104
140
  * {
105
141
  * reviewRequired: boolean,
106
- * reviewTarget: object | null,
107
142
  * artifacts: array,
143
+ * // when reviewRequired is true:
144
+ * reviewTarget: { type: "pull_request", label: string, url: string } | { type: "artifact_set", label: string },
145
+ * reviewActions: [{ kind: "approve", ... }, { kind: "request_changes", ... }],
108
146
  * }
147
+ *
148
+ * Hub normalizers (server.ts + script.js) both read reviewTarget.type, not .kind.
149
+ * Using reviewTarget.kind produces a payload that passes validation but is silently
150
+ * un-renderable. This validator enforces the renderable contract so malformed payloads
151
+ * are rejected before phase advancement.
109
152
  */
110
153
  function validateReviewHandoff(value) {
111
154
  if (value === undefined || value === null) {
@@ -119,7 +162,42 @@ function validateReviewHandoff(value) {
119
162
  if (typeof obj.reviewRequired !== 'boolean') {
120
163
  errors.push(`evidence.reviewHandoff.reviewRequired must be a boolean (got ${obj.reviewRequired === undefined ? 'missing' : typeof obj.reviewRequired})`);
121
164
  }
165
+ // artifacts must always be present and be an array
166
+ if (!('artifacts' in obj) || !Array.isArray(obj.artifacts)) {
167
+ errors.push('evidence.reviewHandoff.artifacts must be an array');
168
+ }
122
169
  if (obj.reviewRequired === true) {
170
+ const target = obj.reviewTarget;
171
+ if (target === undefined || target === null) {
172
+ errors.push('evidence.reviewHandoff.reviewTarget must be a non-null object with type equal to "pull_request" or "artifact_set" when reviewRequired is true');
173
+ }
174
+ else if (typeof target !== 'object' || Array.isArray(target)) {
175
+ errors.push('evidence.reviewHandoff.reviewTarget must be an object with type equal to "pull_request" or "artifact_set"');
176
+ }
177
+ else {
178
+ const t = target;
179
+ if ('kind' in t && !('type' in t)) {
180
+ errors.push(`evidence.reviewHandoff.reviewTarget uses kind but must use type — use type, not kind (e.g. { type: "pull_request", label: "...", url: "https://..." })`);
181
+ }
182
+ else if (t.type !== 'pull_request' && t.type !== 'artifact_set') {
183
+ errors.push(`evidence.reviewHandoff.reviewTarget.type must be "pull_request" or "artifact_set" (got ${t.type === undefined ? 'missing' : JSON.stringify(t.type)})`);
184
+ }
185
+ else if (t.type === 'pull_request') {
186
+ if (!isAbsoluteHttpUrl(t.url)) {
187
+ errors.push(`evidence.reviewHandoff.reviewTarget.url must be an absolute http or https URL for a pull_request target (got ${t.url === undefined ? 'missing' : JSON.stringify(t.url)})`);
188
+ }
189
+ if (Array.isArray(obj.artifacts) && obj.artifacts.length > 0) {
190
+ errors.push('evidence.reviewHandoff.artifacts must be empty for a pull_request target — the PR is the complete review surface; set artifacts: []');
191
+ }
192
+ }
193
+ else if (t.type === 'artifact_set') {
194
+ if (Array.isArray(obj.artifacts)) {
195
+ obj.artifacts.forEach((art, i) => {
196
+ errors.push(...validateReviewArtifactEntry(art, i));
197
+ });
198
+ }
199
+ }
200
+ }
123
201
  if (!Array.isArray(obj.reviewActions) || obj.reviewActions.length === 0) {
124
202
  errors.push('evidence.reviewHandoff.reviewActions must be a non-empty array when reviewRequired is true');
125
203
  }
@@ -135,6 +213,12 @@ function validateReviewHandoff(value) {
135
213
  }
136
214
  }
137
215
  }
216
+ else if (obj.reviewRequired === false && Array.isArray(obj.artifacts)) {
217
+ // For reviewRequired:false, validate any supplied artifact entries (no approval semantics required)
218
+ obj.artifacts.forEach((art, i) => {
219
+ errors.push(...validateReviewArtifactEntry(art, i));
220
+ });
221
+ }
138
222
  return errors.length > 0 ? errors : null;
139
223
  }
140
224
  /**
@@ -152,18 +236,39 @@ function validateNextJobRecommendations(value) {
152
236
  return ['evidence.nextJobRecommendations must be an array'];
153
237
  }
154
238
  const errors = [];
239
+ if (value.length > 3) {
240
+ errors.push('evidence.nextJobRecommendations must contain at most 3 entries');
241
+ }
155
242
  value.forEach((entry, i) => {
156
243
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
157
244
  errors.push(`evidence.nextJobRecommendations[${i}] must be an object`);
158
245
  return;
159
246
  }
160
247
  const rec = entry;
248
+ if ('jobName' in rec && !('jobId' in rec)) {
249
+ errors.push(`evidence.nextJobRecommendations[${i}] uses jobName, but runnable recommendations must use jobId. Use jobId, not jobName.`);
250
+ }
161
251
  if (typeof rec.jobId !== 'string' || !rec.jobId.trim()) {
162
252
  errors.push(`evidence.nextJobRecommendations[${i}].jobId must be a non-empty string`);
163
253
  }
164
254
  if (typeof rec.label !== 'string' || !rec.label.trim()) {
165
255
  errors.push(`evidence.nextJobRecommendations[${i}].label must be a non-empty string`);
166
256
  }
257
+ if (typeof rec.label === 'string' && rec.label.length > 60) {
258
+ errors.push(`evidence.nextJobRecommendations[${i}].label must be 60 characters or fewer`);
259
+ }
260
+ if (rec.reason !== undefined && typeof rec.reason !== 'string') {
261
+ errors.push(`evidence.nextJobRecommendations[${i}].reason must be a string when present`);
262
+ }
263
+ if (typeof rec.reason === 'string' && rec.reason.length > 200) {
264
+ errors.push(`evidence.nextJobRecommendations[${i}].reason must be 200 characters or fewer`);
265
+ }
266
+ if (rec.contextSummary !== undefined && typeof rec.contextSummary !== 'string') {
267
+ errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be a string when present`);
268
+ }
269
+ if (typeof rec.contextSummary === 'string' && rec.contextSummary.length > 300) {
270
+ errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be 300 characters or fewer`);
271
+ }
167
272
  });
168
273
  return errors.length > 0 ? errors : null;
169
274
  }
@@ -282,8 +387,12 @@ const REVIEW_HANDOFF_SCHEMA = `\`\`\`javascript
282
387
  evidence: {
283
388
  reviewHandoff: {
284
389
  reviewRequired: true,
285
- reviewTarget: { kind: "pull_request", url: "<PR URL>" } | { kind: "artifact_set", files: ["<path>"] } | null,
286
- artifacts: [{ label: "<label>", path: "<path>", kind: "<kind>" }],
390
+ // Pull request review (PR is the complete review surface -- set artifacts: []):
391
+ reviewTarget: { type: "pull_request", label: "Pull request", url: "https://github.com/org/repo/pull/N" },
392
+ artifacts: [],
393
+ // -- OR -- artifact set review (local generated files):
394
+ // reviewTarget: { type: "artifact_set", label: "Review bundle" },
395
+ // artifacts: [{ type: "markdown", label: "<label>", path: "<local-path>" }],
287
396
  summary: "<optional summary>",
288
397
  reviewActions: [
289
398
  { kind: "approve", label: "Approve" },
@@ -291,7 +400,8 @@ evidence: {
291
400
  ]
292
401
  }
293
402
  }
294
- \`\`\``;
403
+ \`\`\`
404
+ Note: reviewTarget uses "type" (not "kind"). Action entries use "kind" -- these are different fields.`;
295
405
  const NEXT_JOB_RECOMMENDATIONS_SCHEMA = `\`\`\`javascript
296
406
  evidence: {
297
407
  nextJobRecommendations: [
@@ -15,13 +15,15 @@
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
+ exports.FEEDBACK_PHASE_ID = exports.WAITING_FOR_NEXT_INVOCATION = void 0;
19
19
  exports.resolvePhaseEdge = resolvePhaseEdge;
20
20
  exports.resolveDiscriminant = resolveDiscriminant;
21
21
  exports.discriminantKeys = discriminantKeys;
22
22
  exports.derivePredecessorPhase = derivePredecessorPhase;
23
23
  /** The default discriminant, and the mandatory key on every authored map. */
24
24
  const DEFAULT_DISCRIMINANT = 'default';
25
+ /** Reserved transition target that parks a recurring job until the scheduler invokes it again. */
26
+ exports.WAITING_FOR_NEXT_INVOCATION = 'waiting-for-next-invocation';
25
27
  /**
26
28
  * Resolve an edge to the next phase id.
27
29
  *
@@ -172,6 +172,8 @@ class FraimDbService {
172
172
  this.orgAuditCollection = this.db.collection('fraim_org_audit');
173
173
  // Issue #1345 — job execution modes (Coached/Trusted).
174
174
  this.jobExecutionModesCollection = this.db.collection('fraim_job_execution_modes');
175
+ // Issue #1431 — per-user referral codes.
176
+ this.referralCodesCollection = this.db.collection('fraim_referral_codes');
175
177
  }
176
178
  async initializeIndexes() {
177
179
  if (!this.db)
@@ -252,6 +254,9 @@ class FraimDbService {
252
254
  await this.auditLogCollection.createIndex({ sequence: 1 }).catch(() => { });
253
255
  await this.auditLogCollection.createIndex({ userId: 1, ts: -1 }).catch(() => { });
254
256
  await this.auditLogCollection.createIndex({ ts: -1 }).catch(() => { });
257
+ // Issue #1431 — referral codes: one code per user, globally unique codes.
258
+ await this.referralCodesCollection.createIndex({ userId: 1 }, { unique: true }).catch(() => { });
259
+ await this.referralCodesCollection.createIndex({ code: 1 }, { unique: true }).catch(() => { });
255
260
  }
256
261
  async createSession(session) {
257
262
  if (!this.sessionsCollection)
@@ -613,6 +618,65 @@ class FraimDbService {
613
618
  throw new Error('DB not connected');
614
619
  return await this.signupsCollection.findOne({ email });
615
620
  }
621
+ async setSignupAttribution(email, data) {
622
+ if (!this.signupsCollection)
623
+ throw new Error('DB not connected');
624
+ const now = new Date();
625
+ await this.signupsCollection.updateOne({ email }, {
626
+ $setOnInsert: {
627
+ email,
628
+ name: '',
629
+ company: '',
630
+ source: data.source,
631
+ timestamp: now,
632
+ ipAddress: data.ipAddress,
633
+ userAgent: data.userAgent,
634
+ ...(data.referrerName ? { referrerName: data.referrerName } : {}),
635
+ ...(data.referralCodeUsed ? { referralCodeUsed: data.referralCodeUsed } : {}),
636
+ },
637
+ }, { upsert: true });
638
+ }
639
+ generateReferralCodeString() {
640
+ const crypto = require('crypto');
641
+ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
642
+ const bytes = crypto.randomBytes(12);
643
+ return Array.from(bytes).map((b) => ALPHABET[b % ALPHABET.length]).join('');
644
+ }
645
+ async getReferralCode(userId) {
646
+ if (!this.referralCodesCollection)
647
+ throw new Error('DB not connected');
648
+ return await this.referralCodesCollection.findOne({ userId });
649
+ }
650
+ async getOrCreateReferralCode(userId) {
651
+ if (!this.referralCodesCollection)
652
+ throw new Error('DB not connected');
653
+ const existing = await this.referralCodesCollection.findOne({ userId });
654
+ if (existing)
655
+ return existing.code;
656
+ for (let attempt = 0; attempt < 5; attempt++) {
657
+ const code = this.generateReferralCodeString();
658
+ try {
659
+ await this.referralCodesCollection.insertOne({ userId, code, createdAt: new Date() });
660
+ return code;
661
+ }
662
+ catch (err) {
663
+ if (err?.code === 11000) {
664
+ const row = await this.referralCodesCollection.findOne({ userId });
665
+ if (row)
666
+ return row.code;
667
+ continue;
668
+ }
669
+ throw err;
670
+ }
671
+ }
672
+ throw new Error('Failed to generate unique referral code after retries');
673
+ }
674
+ async validateReferralCode(code) {
675
+ if (!this.referralCodesCollection)
676
+ throw new Error('DB not connected');
677
+ const exists = await this.referralCodesCollection.findOne({ code });
678
+ return exists !== null;
679
+ }
616
680
  /** Get existing API key by userId (email for self-serve) */
617
681
  async getApiKeyByUserId(userId, activeOnly = true) {
618
682
  if (!this.db)
@@ -1131,11 +1131,15 @@ function resolveTeamContextFile(workspaceRoot, key) {
1131
1131
  if (key === 'org' || key === 'orgRules') {
1132
1132
  const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
1133
1133
  if ((0, fs_1.existsSync)(cachePath)) {
1134
+ const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
1135
+ const orgDisplayPath = cachePath.startsWith(fraimDir)
1136
+ ? (0, project_fraim_paths_1.getUserFraimDisplayPath)(cachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, ''))
1137
+ : cachePath.replace(/\\/g, '/');
1134
1138
  return {
1135
1139
  present: true,
1136
1140
  readPath: cachePath,
1137
1141
  writePath: '',
1138
- displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(`org/${relativePath}`),
1142
+ displayPath: orgDisplayPath,
1139
1143
  scope,
1140
1144
  managedByOrgSync: true
1141
1145
  };
@@ -1146,16 +1150,16 @@ function resolveTeamContextFile(workspaceRoot, key) {
1146
1150
  const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
1147
1151
  if ((0, fs_1.existsSync)(managerCachePath)) {
1148
1152
  const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
1149
- const relToFraim = managerCachePath.startsWith(fraimDir)
1150
- ? managerCachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, '')
1151
- : `personalized-employee/${relativePath}`;
1153
+ const mgrDisplayPath = managerCachePath.startsWith(fraimDir)
1154
+ ? (0, project_fraim_paths_1.getUserFraimDisplayPath)(managerCachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, ''))
1155
+ : managerCachePath.replace(/\\/g, '/');
1152
1156
  return {
1153
1157
  present: true,
1154
1158
  readPath: managerCachePath,
1155
- writePath: '',
1156
- displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(relToFraim),
1159
+ writePath: managerHome.requiresPublish ? '' : managerCachePath,
1160
+ displayPath: mgrDisplayPath,
1157
1161
  scope,
1158
- managedByManagerSync: true
1162
+ ...(managerHome.requiresPublish ? { managedByManagerSync: true } : {})
1159
1163
  };
1160
1164
  }
1161
1165
  }
@@ -25,15 +25,22 @@ const learning_context_builder_1 = require("./learning-context-builder");
25
25
  var learning_firing_parser_2 = require("./learning-firing-parser");
26
26
  Object.defineProperty(exports, "parseFiringSection", { enumerable: true, get: function () { return learning_firing_parser_2.parseFiringSection; } });
27
27
  Object.defineProperty(exports, "extractLegacyTitle", { enumerable: true, get: function () { return learning_firing_parser_2.extractLegacyTitle; } });
28
+ const ENTRY_INDEX_READS = [
29
+ { scope: 'manager', level: 'machine' },
30
+ { scope: 'manager', level: 'project' },
31
+ { scope: 'org', level: 'machine' },
32
+ { scope: 'reverse', level: 'machine' },
33
+ { scope: 'reverse', level: 'project' },
34
+ ];
28
35
  function buildEntryIndex(workspaceRoot, userId) {
29
36
  const byId = new Map();
30
37
  const byTitle = new Map();
31
- // Every scope a firing could name: the manager's own entries, the org entries
32
- // the agent also reads, and the manager-coaching family.
33
- for (const scope of ['manager', 'org', 'reverse']) {
38
+ // Keep this list in parity with the preserved-entry levels that the offer
39
+ // path can surface. Storage resolution stays inside readPreservedLearnings.
40
+ for (const { scope, level } of ENTRY_INDEX_READS) {
34
41
  let entries = [];
35
42
  try {
36
- entries = (0, learning_context_builder_1.readPreservedLearnings)(workspaceRoot, userId, scope);
43
+ entries = (0, learning_context_builder_1.readPreservedLearnings)(workspaceRoot, userId, scope, level);
37
44
  }
38
45
  catch {
39
46
  continue;
@@ -53,6 +53,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
53
53
  Object.defineProperty(exports, "__esModule", { value: true });
54
54
  exports.FraimLocalMCPServer = exports.FraimTemplateEngine = void 0;
55
55
  exports.parseExecutionModeFromResponse = parseExecutionModeFromResponse;
56
+ exports.buildExecutionModeForwardUrl = buildExecutionModeForwardUrl;
56
57
  const fs_1 = require("fs");
57
58
  const path_1 = require("path");
58
59
  const os_1 = require("os");
@@ -450,6 +451,19 @@ function parseExecutionModeFromResponse(text) {
450
451
  return null;
451
452
  }
452
453
  }
454
+ // Issue #1443: the Hub's run registry is keyed by the host CLI's own session id
455
+ // (run.sessionId, discovered from host stdout well after spawn — see
456
+ // applyRunHostSessionSignal in ai-hub/server.ts), which has no relationship to the
457
+ // FRAIM/MCP session id this proxy carries on every tool call. A lookup by the latter
458
+ // never matches. FRAIM_HUB_RUN_ID is stamped into this process's env at spawn time
459
+ // (AiHubServer.withHubRunIdEnv) specifically so this forward can target the run
460
+ // directly; fall back to the (unreliable) session-based route only when the Hub that
461
+ // spawned this process predates that fix.
462
+ function buildExecutionModeForwardUrl(hubBase, hubRunId, requestSessionId) {
463
+ return hubRunId
464
+ ? `${hubBase}/api/ai-hub/runs/${encodeURIComponent(hubRunId)}/execution-mode`
465
+ : `${hubBase}/api/ai-hub/runs/by-session/${encodeURIComponent(requestSessionId)}/execution-mode`;
466
+ }
453
467
  class FraimLocalMCPServer {
454
468
  constructor(writer) {
455
469
  this.config = null;
@@ -1427,7 +1441,8 @@ class FraimLocalMCPServer {
1427
1441
  const hubBase = process.env.FRAIM_HUB_BASE_URL;
1428
1442
  const em = parseExecutionModeFromResponse(responseText);
1429
1443
  if (hubBase && em) {
1430
- axios_1.default.post(`${hubBase}/api/ai-hub/runs/by-session/${encodeURIComponent(requestSessionId)}/execution-mode`, em, { headers: { 'Content-Type': 'application/json' } }).catch((err) => {
1444
+ const forwardUrl = buildExecutionModeForwardUrl(hubBase, process.env.FRAIM_HUB_RUN_ID, requestSessionId);
1445
+ axios_1.default.post(forwardUrl, em, { headers: { 'Content-Type': 'application/json' } }).catch((err) => {
1431
1446
  this.log(`[req:${requestId}] execution-mode forward failed: ${err instanceof Error ? err.message : String(err)}`);
1432
1447
  });
1433
1448
  }
@@ -1500,6 +1515,25 @@ class FraimLocalMCPServer {
1500
1515
  return null;
1501
1516
  }
1502
1517
  }
1518
+ async validateNextJobRecommendationJobIds(value, mentor) {
1519
+ if (!Array.isArray(value))
1520
+ return [];
1521
+ const errors = [];
1522
+ for (let i = 0; i < value.length; i++) {
1523
+ const entry = value[i];
1524
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
1525
+ continue;
1526
+ const jobId = entry.jobId;
1527
+ if (typeof jobId !== 'string' || !jobId.trim())
1528
+ continue;
1529
+ const normalizedJobId = jobId.trim();
1530
+ const job = await mentor.getJobOverview(normalizedJobId);
1531
+ if (!job) {
1532
+ errors.push(`evidence.nextJobRecommendations[${i}].jobId must reference a known FRAIM job (got "${normalizedJobId}")`);
1533
+ }
1534
+ }
1535
+ return errors;
1536
+ }
1503
1537
  async finalizeLocalToolTextResponse(request, requestSessionId, requestId, text) {
1504
1538
  const response = {
1505
1539
  jsonrpc: '2.0',
@@ -2255,6 +2289,7 @@ class FraimLocalMCPServer {
2255
2289
  findings: args.findings,
2256
2290
  phases: handoffPhaseMap,
2257
2291
  });
2292
+ handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
2258
2293
  if (handoffErrors.length > 0) {
2259
2294
  const missingField = handoffErrors[0].includes('reviewHandoff') ? 'reviewHandoff'
2260
2295
  : handoffErrors[0].includes('nextJobRecommendations') ? 'nextJobRecommendations'
@@ -38,7 +38,7 @@ class AuthMiddleware {
38
38
  res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${mcpResourceMetadataUrl}"`);
39
39
  }
40
40
  };
41
- const publicPrefixes = ['/admin', '/dashboard', '/health', '/pricing', '/fraim-brain', '/installers', '/api/signup', '/api/sales', '/api/request-access', '/api/installer-key', '/api/installer-download', '/api/installer-availability', '/api/payment/bypass', '/api/pricing', '/api/personas/catalog', '/api/ai-hub/ui/releases', '/ai-hub-remote', '/auth', '/portfolio'];
41
+ const publicPrefixes = ['/admin', '/dashboard', '/health', '/pricing', '/fraim-brain', '/installers', '/api/signup', '/api/sales', '/api/request-access', '/api/installer-key', '/api/installer-download', '/api/installer-availability', '/api/payment/bypass', '/api/pricing', '/api/personas/catalog', '/api/ai-hub/ui/releases', '/ai-hub-remote', '/auth', '/portfolio', '/api/referral'];
42
42
  // Analytics dashboard is public, but API routes are protected
43
43
  const isAnalyticsPublic = p === '/analytics' || p === '/analytics/' || p.startsWith('/analytics/') && p.endsWith('.html');
44
44
  const isOAuthMetadataPublic = p === '/.well-known/oauth-authorization-server'
@@ -417,6 +417,45 @@ function registerProtectedAuthRoutes(app, deps) {
417
417
  return res.status(500).json({ ok: false });
418
418
  }
419
419
  });
420
+ app.get('/api/account/referral-code', async (req, res) => {
421
+ try {
422
+ const apiKeyData = req.apiKeyData;
423
+ if (!apiKeyData)
424
+ return res.status(401).json({ ok: false });
425
+ const record = await dbService.getReferralCode(apiKeyData.userId);
426
+ return res.status(200).json({ code: record?.code ?? null });
427
+ }
428
+ catch (err) {
429
+ console.error('[FRAIM AUTH] /api/account/referral-code GET error:', err instanceof Error ? err.message : String(err));
430
+ return res.status(500).json({ ok: false });
431
+ }
432
+ });
433
+ app.post('/api/account/referral-code', async (req, res) => {
434
+ try {
435
+ const apiKeyData = req.apiKeyData;
436
+ if (!apiKeyData)
437
+ return res.status(401).json({ ok: false });
438
+ const code = await dbService.getOrCreateReferralCode(apiKeyData.userId);
439
+ return res.status(200).json({ code });
440
+ }
441
+ catch (err) {
442
+ console.error('[FRAIM AUTH] /api/account/referral-code POST error:', err instanceof Error ? err.message : String(err));
443
+ return res.status(500).json({ ok: false });
444
+ }
445
+ });
446
+ app.get('/api/referral/validate', async (req, res) => {
447
+ try {
448
+ const code = String(req.query.code || '').trim().toUpperCase();
449
+ if (!code)
450
+ return res.status(200).json({ valid: false });
451
+ const valid = await dbService.validateReferralCode(code);
452
+ return res.status(200).json({ valid });
453
+ }
454
+ catch (err) {
455
+ console.error('[FRAIM AUTH] /api/referral/validate error:', err instanceof Error ? err.message : String(err));
456
+ return res.status(500).json({ valid: false });
457
+ }
458
+ });
420
459
  app.get('/api/account/activity', async (req, res) => {
421
460
  try {
422
461
  const apiKeyData = req.apiKeyData;
@@ -118,7 +118,7 @@ function registerOAuthRoutes(app, deps) {
118
118
  });
119
119
  // Helper used by both providers to finalise authentication once we have a verified email.
120
120
  async function completeSignIn(req, res, params) {
121
- const { verifiedEmail, provider, surface, redirectTo, intent, hubReturn } = params;
121
+ const { verifiedEmail, provider, surface, redirectTo, intent, hubReturn, referrerName, referralCode } = params;
122
122
  const lower = verifiedEmail.toLowerCase();
123
123
  const apiKeyData = await dbService.getApiKeyByUserId(lower, false);
124
124
  let apiKeyForHubReturn = apiKeyData?.key ?? '';
@@ -141,6 +141,17 @@ function registerOAuthRoutes(app, deps) {
141
141
  }
142
142
  if (!apiKeyData && intent === 'signup') {
143
143
  apiKeyForHubReturn = await (0, account_provisioning_1.ensureTrialApiKey)(dbService, lower);
144
+ // Issue #1431: write attribution once for new OAuth accounts. Uses upsert
145
+ // with $setOnInsert so repeat signups never overwrite original attribution.
146
+ if (referrerName || referralCode) {
147
+ await dbService.setSignupAttribution(lower, {
148
+ source: `oauth:${provider}`,
149
+ ...(referrerName ? { referrerName } : {}),
150
+ ...(referralCode ? { referralCodeUsed: referralCode.toUpperCase() } : {}),
151
+ userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : undefined,
152
+ ipAddress: (0, oauth_helpers_1.clientIpFromReq)(req),
153
+ }).catch(() => { });
154
+ }
144
155
  }
145
156
  const sessionId = (0, oauth_helpers_1.generateSessionId)();
146
157
  await dbService.createAuthSession({
@@ -174,6 +185,8 @@ function registerOAuthRoutes(app, deps) {
174
185
  const intent = pickIntent(req.query.intent);
175
186
  const redirectTo = (0, oauth_helpers_1.safeRedirectPath)(req.query.redirect_to, (0, oauth_helpers_1.defaultRedirectForSurface)(surface));
176
187
  const hubReturn = safeLoopbackHubReturn(req.query.hub_return);
188
+ const referrerName = typeof req.query.referrer_name === 'string' ? req.query.referrer_name.trim().slice(0, 200) : undefined;
189
+ const referralCode = typeof req.query.referral_code === 'string' ? req.query.referral_code.trim().toUpperCase().slice(0, 20) : undefined;
177
190
  try {
178
191
  const codeVerifier = (0, oauth_helpers_1.generatePkceVerifier)();
179
192
  const stateNonce = (0, oauth_helpers_1.generateStateNonce)();
@@ -187,6 +200,8 @@ function registerOAuthRoutes(app, deps) {
187
200
  stateNonce,
188
201
  redirectTo,
189
202
  hubReturn,
203
+ ...(referrerName ? { referrerName } : {}),
204
+ ...(referralCode ? { referralCode } : {}),
190
205
  });
191
206
  (0, cookie_service_1.setOAuthPendingCookie)(res, pendingId);
192
207
  await (0, audit_log_1.auditLog)('OAUTH_START', { provider, surface, ip: (0, oauth_helpers_1.clientIpFromReq)(req), outcome: 'success' });
@@ -297,6 +312,8 @@ function registerOAuthRoutes(app, deps) {
297
312
  redirectTo: pending.redirectTo,
298
313
  intent: pickIntent(pending.intent),
299
314
  hubReturn: pending.hubReturn,
315
+ referrerName: pending.referrerName,
316
+ referralCode: pending.referralCode,
300
317
  });
301
318
  }
302
319
  catch (err) {
@@ -391,6 +408,8 @@ function registerOAuthRoutes(app, deps) {
391
408
  redirectTo: pending.redirectTo,
392
409
  intent: pickIntent(pending.intent),
393
410
  hubReturn: pending.hubReturn,
411
+ referrerName: pending.referrerName,
412
+ referralCode: pending.referralCode,
394
413
  });
395
414
  }
396
415
  catch (err) {
@@ -130,7 +130,7 @@ class AdminService {
130
130
  * Security: prevents unauthorized access by requiring email ownership verification.
131
131
  */
132
132
  async requestAccess(body, req) {
133
- const { email, name, company, useCase } = body;
133
+ const { email, name, company, useCase, referrerName, referralCode } = body;
134
134
  // Issue #691: signup is now email + verification code only. Name/company
135
135
  // are optional lead-capture fields (createWebsiteSignup below already
136
136
  // tolerates missing values); the trial key itself never required them.
@@ -150,11 +150,14 @@ class AdminService {
150
150
  source: 'request-access',
151
151
  timestamp: new Date(),
152
152
  ipAddress,
153
- userAgent
153
+ userAgent,
154
+ ...(referrerName ? { referrerName: referrerName.trim() } : {}),
155
+ ...(referralCode ? { referralCodeUsed: referralCode.trim().toUpperCase() } : {}),
154
156
  });
155
157
  }
156
158
  catch {
157
159
  // Ignore duplicate or other signup errors; email verification still proceeds.
160
+ // Attribution write-once: duplicate emails silently fail, preserving first attribution.
158
161
  }
159
162
  const verificationToken = (0, crypto_1.randomBytes)(32).toString('hex');
160
163
  const verificationCode = (0, email_code_1.generateEmailCode)();
@@ -166,7 +169,9 @@ class AdminService {
166
169
  verified: false,
167
170
  expiresAt,
168
171
  createdAt: new Date(),
169
- usedAt: null
172
+ usedAt: null,
173
+ ...(referrerName ? { referrerName: referrerName.trim() } : {}),
174
+ ...(referralCode ? { referralCode: referralCode.trim().toUpperCase() } : {}),
170
175
  });
171
176
  const emailService = new email_service_1.EmailService();
172
177
  const baseUrl = process.env.BASE_URL || 'https://fraimworks.ai';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.297",
3
+ "version": "2.0.299",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {