fraim 2.0.311 → 2.0.312

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.
@@ -72,7 +72,7 @@ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
72
72
  If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
73
73
 
74
74
  3. **Find the match**:
75
- If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification. Do not pick the nearest catalog job.
75
+ If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, ask once: "No catalog job matches. Would you like to run this as an ad-hoc task?" On confirmation, call \`get_fraim_job({ job: "adhoc-prompt" })\` and execute it with the user's instructions as the task input do not pick the nearest catalog job. Do not ask again if the user already provided instructions.
76
76
 
77
77
  4. **Load the full content**:
78
78
  - For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
@@ -125,6 +125,10 @@ function ensureUserLevelDependencies(userFraimDir) {
125
125
  if (missing.length === 0) {
126
126
  return;
127
127
  }
128
+ if (process.env.FRAIM_SKIP_USER_LEVEL_DEP_INSTALL === '1') {
129
+ console.log(chalk_1.default.yellow(`TEST_MODE: skipping user-level runtime dependency install (${missing.join(', ')}).`));
130
+ return;
131
+ }
128
132
  console.log(chalk_1.default.blue(`📦 Installing user-level runtime dependencies (${missing.join(', ')})...`));
129
133
  try {
130
134
  (0, child_process_1.execSync)('npm install --no-audit --no-fund --no-save --no-package-lock --omit=dev', {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PERSONA_CAPABILITY_BUNDLES = exports.FREE_JOBS = exports.GENERIC_WORKER_PERSONA_KEY = void 0;
3
+ exports.UNOWNED_EXEMPT_JOBS = exports.PERSONA_CAPABILITY_BUNDLES = exports.FREE_JOBS = exports.GENERIC_WORKER_PERSONA_KEY = void 0;
4
4
  exports.isFreeJob = isFreeJob;
5
5
  exports.getPersonaCapabilityBundle = getPersonaCapabilityBundle;
6
6
  exports.getProtectedPersonaForJob = getProtectedPersonaForJob;
@@ -315,6 +315,17 @@ const GENERIC_WORKER_OWNED_JOBS = new Set([
315
315
  'organization-onboarding',
316
316
  'organizational-learning-synthesis',
317
317
  ]);
318
+ // Jobs intentionally left without a named persona owner. They resolve to null
319
+ // from getProtectedPersonaForJob (runs ungated; Hub attributes them to the
320
+ // DEFAULT_UNASSIGNED_PERSONA_KEY/MANdy — same behavior as today's adhoc runs).
321
+ // validate-job-ownership exempts these from the "every job must have an owner"
322
+ // assertion so the validator still catches accidentally unowned jobs.
323
+ //
324
+ // Issue #1610: adhoc-prompt is a manager-directed fallback job, not tied to any
325
+ // specialist hire, matching the existing no-employee attribution of adhoc runs.
326
+ exports.UNOWNED_EXEMPT_JOBS = new Set([
327
+ 'adhoc-prompt',
328
+ ]);
318
329
  function getPersonaCapabilityBundle(personaKey) {
319
330
  return exports.PERSONA_CAPABILITY_BUNDLES[personaKey];
320
331
  }
@@ -81,6 +81,7 @@ const skill_include_dedup_js_1 = require("./skill-include-dedup.js");
81
81
  */
82
82
  class FraimTemplateEngine {
83
83
  constructor(opts) {
84
+ this.configLoadAttempted = false;
84
85
  this.userEmail = null;
85
86
  this.deliveryTemplatesCache = null;
86
87
  this.providerTemplatesCache = {};
@@ -117,6 +118,7 @@ class FraimTemplateEngine {
117
118
  }
118
119
  setConfig(config) {
119
120
  this.config = config;
121
+ this.configLoadAttempted = true;
120
122
  }
121
123
  setUserEmail(email) {
122
124
  this.userEmail = email;
@@ -124,6 +126,57 @@ class FraimTemplateEngine {
124
126
  getUserEmail() {
125
127
  return this.userEmail;
126
128
  }
129
+ resolveEmailLazy() {
130
+ if (this.userEmail)
131
+ return this.userEmail;
132
+ try {
133
+ const dir = (0, project_fraim_paths_1.getUserFraimDirPath)();
134
+ const prefsPath = (0, path_1.join)(dir, 'preferences.json');
135
+ if ((0, fs_1.existsSync)(prefsPath)) {
136
+ const prefs = JSON.parse((0, fs_1.readFileSync)(prefsPath, 'utf8'));
137
+ if (typeof prefs.userEmail === 'string' && prefs.userEmail) {
138
+ this.userEmail = prefs.userEmail;
139
+ return this.userEmail;
140
+ }
141
+ }
142
+ }
143
+ catch { /* ignore */ }
144
+ return null;
145
+ }
146
+ resolveMachineInfoLazy() {
147
+ if (this.machineInfo)
148
+ return this.machineInfo;
149
+ try {
150
+ this.machineInfo = {
151
+ hostname: (0, os_1.hostname)(),
152
+ platform: (0, os_1.platform)(),
153
+ memory: (0, os_1.totalmem)(),
154
+ cpus: (0, os_1.cpus)().length
155
+ };
156
+ }
157
+ catch { /* ignore */ }
158
+ return this.machineInfo;
159
+ }
160
+ resolveConfigLazy() {
161
+ if (this.config)
162
+ return this.config;
163
+ // If setConfig() was called explicitly (including with null), respect that — no disk read.
164
+ if (this.configLoadAttempted)
165
+ return null;
166
+ if (!this.projectRoot)
167
+ return null;
168
+ try {
169
+ const configPath = (0, path_1.join)(this.projectRoot, 'fraim', 'config.json');
170
+ if ((0, fs_1.existsSync)(configPath)) {
171
+ this.config = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
172
+ this.configLoadAttempted = true;
173
+ return this.config;
174
+ }
175
+ }
176
+ catch { /* ignore */ }
177
+ this.configLoadAttempted = true;
178
+ return null;
179
+ }
127
180
  substituteTemplates(content) {
128
181
  return this.substituteTemplatesWithNotices(content).content;
129
182
  }
@@ -131,15 +184,16 @@ class FraimTemplateEngine {
131
184
  let result = content;
132
185
  const notices = [];
133
186
  const blockingRequirements = [];
134
- // Substitute {{proxy.user.email}} with the email captured from fraim_connect
135
- if (this.userEmail) {
136
- result = result.replace(/\{\{proxy\.user\.email\}\}/g, this.userEmail);
187
+ // Substitute {{proxy.user.email}} resolved lazily from preferences.json when fraim_connect was skipped.
188
+ const resolvedEmail = this.resolveEmailLazy();
189
+ if (resolvedEmail) {
190
+ result = result.replace(/\{\{proxy\.user\.email\}\}/g, resolvedEmail);
137
191
  }
138
192
  // 0. Substitute runtime context tokens: {{agent.*}}, {{machine.*}}, {{repository.*}}
139
- // These come from the fraim_connect payload captured during handshake.
193
+ // Machine info is resolved lazily from the OS when fraim_connect was skipped.
140
194
  const contexts = {
141
195
  agent: this.agentInfo,
142
- machine: this.machineInfo,
196
+ machine: this.resolveMachineInfoLazy(),
143
197
  repository: this.repoInfo
144
198
  };
145
199
  result = result.replace(/\{\{(agent|machine|repository)\.([^}]+)\}\}/g, (match, ns, path) => {
@@ -207,8 +261,9 @@ class FraimTemplateEngine {
207
261
  return originalMatch;
208
262
  }
209
263
  try {
210
- if (this.config) {
211
- const value = (0, object_utils_1.getNestedValue)(this.config, path);
264
+ const config = this.resolveConfigLazy();
265
+ if (config) {
266
+ const value = (0, object_utils_1.getNestedValue)(config, path);
212
267
  if (value !== undefined) {
213
268
  return typeof value === 'object' ? JSON.stringify(value) : String(value);
214
269
  }
@@ -791,20 +846,25 @@ class FraimLocalMCPServer {
791
846
  return;
792
847
  }
793
848
  if (this.connectSyncInFlight) {
794
- await this.connectSyncInFlight;
849
+ this.maybeRefreshOrgCache(String(requestId));
850
+ this.maybeRefreshManagerCache(String(requestId));
851
+ return;
852
+ }
853
+ const projectRoot = this.findProjectRoot();
854
+ this.latestConnectSyncWarning = null;
855
+ if (!projectRoot || !(0, project_fraim_paths_1.workspaceFraimExists)(projectRoot)) {
856
+ this.maybeRefreshOrgCache(String(requestId));
857
+ this.maybeRefreshManagerCache(String(requestId));
858
+ return;
859
+ }
860
+ const reason = this.getLocalCatalogSyncReason(projectRoot);
861
+ if (!reason) {
862
+ this.maybeRefreshOrgCache(String(requestId));
863
+ this.maybeRefreshManagerCache(String(requestId));
795
864
  return;
796
865
  }
866
+ this.log(`[req:${requestId}] Refreshing local FRAIM catalog in background because ${reason}`);
797
867
  this.connectSyncInFlight = (async () => {
798
- const projectRoot = this.findProjectRoot();
799
- this.latestConnectSyncWarning = null;
800
- if (!projectRoot || !(0, project_fraim_paths_1.workspaceFraimExists)(projectRoot)) {
801
- return;
802
- }
803
- const reason = this.getLocalCatalogSyncReason(projectRoot);
804
- if (!reason) {
805
- return;
806
- }
807
- this.log(`[req:${requestId}] Refreshing local FRAIM catalog before fraim_connect because ${reason}`);
808
868
  try {
809
869
  await this.performLocalCatalogSync(projectRoot);
810
870
  this.log(`[req:${requestId}] Local FRAIM catalog refresh complete`);
@@ -814,13 +874,10 @@ class FraimLocalMCPServer {
814
874
  this.latestConnectSyncWarning = `Local FRAIM catalog refresh failed: ${message}. Discovery will fall back to remote FRAIM tools until sync succeeds.`;
815
875
  this.logError(`[req:${requestId}] ${this.latestConnectSyncWarning}`);
816
876
  }
877
+ finally {
878
+ this.connectSyncInFlight = null;
879
+ }
817
880
  })();
818
- try {
819
- await this.connectSyncInFlight;
820
- }
821
- finally {
822
- this.connectSyncInFlight = null;
823
- }
824
881
  this.maybeRefreshOrgCache(String(requestId));
825
882
  this.maybeRefreshManagerCache(String(requestId));
826
883
  }
@@ -905,10 +962,63 @@ class FraimLocalMCPServer {
905
962
  }
906
963
  }
907
964
  /**
908
- * Automatically detect repository information from git
965
+ * Derive the issue worktree directory `prep-issue.sh` would have created for
966
+ * `issueNumber`, without requiring any caller to supply it (issue #1641
967
+ * independent-review follow-up: an opt-in `workingDirectory` parameter that
968
+ * nothing instructs an agent to pass is a recall-dependent fix, not a
969
+ * mechanical one). `prep-issue.sh` names the worktree deterministically —
970
+ * `registry/scripts/prep-issue.sh`: `WORKTREE_DIR="$REPO_NAME - Issue
971
+ * $ISSUE_NUMBER"`, `WORKTREE_PATH="$PARENT_DIR/$WORKTREE_DIR"` — as a sibling
972
+ * of whatever directory the script was run from, using the repository name
973
+ * from `fraim/config.json`, not the actual directory's basename. This
974
+ * reproduces that exact derivation from state this process already has
975
+ * (`this.workspaceRoot`/`findProjectRoot()` for the parent directory,
976
+ * `this.config.repository.name` for the repo name, `issueNumber` from the
977
+ * `seekMentoring` call itself) and returns the candidate only if it actually
978
+ * exists on disk — never inventing an override for a directory that isn't
979
+ * there. Returns `null` when any required input is missing or the derived
980
+ * candidate does not exist, in which case the caller falls back to an
981
+ * explicit override or the cached default.
982
+ */
983
+ deriveIssueWorktreeDirectory(issueNumber) {
984
+ if (!issueNumber || typeof issueNumber !== 'string' || !issueNumber.trim())
985
+ return null;
986
+ const repoName = this.config?.repository?.name;
987
+ if (!repoName)
988
+ return null;
989
+ const primaryRoot = this.workspaceRoot || this.findProjectRoot();
990
+ if (!primaryRoot)
991
+ return null;
992
+ try {
993
+ const candidate = (0, path_1.join)((0, path_1.dirname)(primaryRoot), `${repoName} - Issue ${issueNumber}`);
994
+ return (0, fs_1.existsSync)(candidate) ? candidate : null;
995
+ }
996
+ catch {
997
+ return null;
998
+ }
999
+ }
1000
+ /**
1001
+ * Automatically detect repository information from git.
1002
+ *
1003
+ * `overrideProjectDir` (issue #1641): this server process is long-lived and
1004
+ * its notion of "project root" (`this.workspaceRoot`, from the MCP client's
1005
+ * one-time `roots/list` response, or `process.cwd()` at process start) never
1006
+ * changes for the life of the process. A FRAIM job that moves its actual
1007
+ * work into an issue worktree (a sibling directory created by
1008
+ * `prep-issue.sh`) does so entirely within the calling agent's own shell —
1009
+ * this process is never told. Without an override, `detectRepoInfo()` keeps
1010
+ * reporting the branch of wherever it originally resolved (typically the
1011
+ * primary checkout), not the worktree the agent is actually operating in.
1012
+ * When the caller supplies a real directory (the agent's own known working
1013
+ * directory), that directory is used for this call only: the result is not
1014
+ * written to the process-lifetime `this.repoInfo` cache, so it cannot leak
1015
+ * into unrelated callers that rely on the default project's cached identity.
909
1016
  */
910
- detectRepoInfo() {
911
- if (this.repoInfo) {
1017
+ detectRepoInfo(overrideProjectDir) {
1018
+ const useOverride = typeof overrideProjectDir === 'string'
1019
+ && overrideProjectDir.trim() !== ''
1020
+ && (0, fs_1.existsSync)(overrideProjectDir);
1021
+ if (!useOverride && this.repoInfo) {
912
1022
  return this.repoInfo;
913
1023
  }
914
1024
  // Ensure config is loaded before trying to detect repo info
@@ -916,7 +1026,7 @@ class FraimLocalMCPServer {
916
1026
  this.loadConfig();
917
1027
  }
918
1028
  try {
919
- const projectDir = this.findProjectRoot() || process.cwd();
1029
+ const projectDir = useOverride ? overrideProjectDir : (this.findProjectRoot() || process.cwd());
920
1030
  // Try to get git remote URL
921
1031
  let repoUrl = '';
922
1032
  try {
@@ -998,6 +1108,15 @@ class FraimLocalMCPServer {
998
1108
  if (owner) {
999
1109
  repoInfo.owner = owner;
1000
1110
  }
1111
+ if (useOverride) {
1112
+ // One-off lookup for a caller-supplied directory: do not clobber the
1113
+ // process-lifetime cache or the default project's usage identifier.
1114
+ const repoLabel = repoInfo.owner
1115
+ ? `${repoInfo.owner}/${repoInfo.name}`
1116
+ : repoInfo.projectPath || repoInfo.name;
1117
+ this.log(`Detected repo info for override directory ${projectDir}: ${repoLabel}`);
1118
+ return repoInfo;
1119
+ }
1001
1120
  this.repoInfo = repoInfo;
1002
1121
  this.usageCollector.setRepoIdentifier(repoInfo.url || null);
1003
1122
  const repoLabel = this.repoInfo.owner
@@ -2373,7 +2492,21 @@ class FraimLocalMCPServer {
2373
2492
  // for this purpose only when a defaultBranch reference point
2374
2493
  // exists; without it the check cannot classify default-vs-feature
2375
2494
  // branch, so it must not apply (repo: null).
2376
- const handoffRepoInfo = this.detectRepoInfo();
2495
+ //
2496
+ // Issue #1641: this process's default project directory (cached
2497
+ // workspaceRoot / process.cwd()) does not track a worktree the
2498
+ // agent moved its actual work into. Deriving the expected worktree
2499
+ // path from prep-issue.sh's own naming convention (repo name +
2500
+ // issue number, both already known to this process) is mechanical
2501
+ // and requires no agent action, unlike depending on every job/skill
2502
+ // that reaches submission to remember to pass a hint — an explicit
2503
+ // `workingDirectory` remains available as an escape hatch for a
2504
+ // worktree this derivation cannot find (non-standard layout,
2505
+ // renamed directory), and wins when supplied. detectRepoInfo() does
2506
+ // not cache either path, so neither can affect any other lookup.
2507
+ const explicitWorkingDirectory = typeof args.workingDirectory === 'string' ? args.workingDirectory : undefined;
2508
+ const derivedWorkingDirectory = this.deriveIssueWorktreeDirectory(args.issueNumber);
2509
+ const handoffRepoInfo = this.detectRepoInfo(explicitWorkingDirectory || derivedWorkingDirectory || undefined);
2377
2510
  const handoffRepoState = handoffRepoInfo?.defaultBranch
2378
2511
  ? { currentBranch: handoffRepoInfo.branch || '', defaultBranch: handoffRepoInfo.defaultBranch }
2379
2512
  : null;
@@ -366,6 +366,10 @@ Do not use this tool for other repositories or external project issue trackers.`
366
366
  type: 'object',
367
367
  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. Retrospective phases may put follow-on job recommendations in evidence.nextJobRecommendations (array of { jobId, label, reason?, contextSummary? }, max 3) so the work surface can offer them as next steps.',
368
368
  additionalProperties: true
369
+ },
370
+ workingDirectory: {
371
+ type: 'string',
372
+ description: 'Absolute path to the directory you are actually working in right now, when it differs from this session\'s default project directory — most commonly an issue worktree created by prep-issue.sh. Pass this on submit-phase calls carrying evidence.reviewHandoff so the review-action-completeness check reads the branch of your real worktree instead of a directory this long-lived session cached earlier. Optional; omit when working in the default project directory.'
369
373
  }
370
374
  },
371
375
  required: requiredWithSession(['jobName', 'jobId', 'issueNumber', 'currentPhase', 'status'])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.311",
3
+ "version": "2.0.312",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {