fraim-framework 2.0.192 → 2.0.194

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.
@@ -23,6 +23,7 @@ const child_process_1 = require("child_process");
23
23
  const fs_1 = __importDefault(require("fs"));
24
24
  const os_1 = __importDefault(require("os"));
25
25
  const path_1 = __importDefault(require("path"));
26
+ const manager_turns_1 = require("./manager-turns");
26
27
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
27
28
  const mcp_config_generator_1 = require("../cli/setup/mcp-config-generator");
28
29
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
@@ -702,11 +703,11 @@ function transformHeadlessFraimMessage(message, kind) {
702
703
  if (!parsed)
703
704
  return message;
704
705
  if (!parsed.jobId) {
706
+ // Issue #732: single-source the same-job continue wording via
707
+ // buildSameJobContinueMessage so the server (prepareContinueMessage) and
708
+ // this headless transform stay in lock-step.
705
709
  if (kind === 'continue') {
706
- if (parsed.remainder) {
707
- return `Continue the active FRAIM job with this manager coaching:\n\n${parsed.remainder}`;
708
- }
709
- return 'Continue the active FRAIM job using the current session context.';
710
+ return (0, manager_turns_1.buildSameJobContinueMessage)(parsed.remainder);
710
711
  }
711
712
  return message;
712
713
  }
@@ -1351,6 +1352,10 @@ class FakeHostRuntime {
1351
1352
  { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
1352
1353
  { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true },
1353
1354
  ];
1355
+ // Remembered across turns like a resumed agent session: the job label from the
1356
+ // start turn. Issue #732 — a same-job continue no longer carries a /fraim <job>
1357
+ // invocation, so the fake falls back to this to stay job-aware on continue.
1358
+ this.lastJobLabel = null;
1354
1359
  }
1355
1360
  detectEmployees() {
1356
1361
  return this.employees;
@@ -1407,9 +1412,12 @@ class FakeHostRuntime {
1407
1412
  }
1408
1413
  fakeEmployeeReply(kind, message) {
1409
1414
  const parsed = parseFraimInvocation(message);
1410
- const label = parsed?.jobId
1415
+ const parsedLabel = parsed?.jobId
1411
1416
  ? parsed.jobId.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ')
1412
1417
  : null;
1418
+ if (parsedLabel)
1419
+ this.lastJobLabel = parsedLabel;
1420
+ const label = parsedLabel || (kind === 'continue' ? this.lastJobLabel : null);
1413
1421
  if (kind === 'continue') {
1414
1422
  return label
1415
1423
  ? `Understood. I'm taking another pass with ${label}.`
@@ -1442,6 +1450,9 @@ class ScriptedHostRuntime {
1442
1450
  // Tracks runDiscriminant per sessionId so consecutive emitPhase calls
1443
1451
  // can resolve onSuccess routing without each test having to repeat it.
1444
1452
  this.discriminantBySession = new Map();
1453
+ // Issue #732: capture the exact message the Hub sent on the most recent
1454
+ // continue turn so tests can assert whether it would re-trigger get_fraim_job.
1455
+ this.lastContinueMessage = null;
1445
1456
  }
1446
1457
  detectEmployees() {
1447
1458
  return this.employees;
@@ -1452,7 +1463,8 @@ class ScriptedHostRuntime {
1452
1463
  handlers.onEvent({ sessionId, raw: 'scripted-session-start' }, 'system');
1453
1464
  return this.spawnDouble();
1454
1465
  }
1455
- continueRun(_hostId, _projectPath, sessionId, _message, handlers) {
1466
+ continueRun(_hostId, _projectPath, sessionId, message, handlers) {
1467
+ this.lastContinueMessage = message;
1456
1468
  this.handlersBySession.set(sessionId, handlers);
1457
1469
  handlers.onEvent({ sessionId, raw: 'scripted-session-resume' }, 'system');
1458
1470
  return this.spawnDouble();
@@ -1517,6 +1529,20 @@ class ScriptedHostRuntime {
1517
1529
  },
1518
1530
  }, 'stdout');
1519
1531
  }
1532
+ // Test API — emit a same-job seekMentoring signal that carries BOTH the
1533
+ // stable jobName slug and a per-invocation UUID jobId, exactly as a real
1534
+ // agent does after get_fraim_job (which mints a fresh randomUUID each call).
1535
+ // Issue #732: the tracker must key off jobName, not the UUID jobId.
1536
+ emitPhaseWithJobIds(runId, phaseId, status, jobName, jobId, findingsText) {
1537
+ const target = this.resolveSession(runId);
1538
+ if (!target)
1539
+ return;
1540
+ target.handlers.onEvent({
1541
+ sessionId: target.sessionId,
1542
+ raw: `scripted-seekMentoring-ids:${phaseId}:${status}`,
1543
+ seekMentoring: { phaseId, phaseStatus: status, jobName, jobId, findingsText },
1544
+ }, 'stdout');
1545
+ }
1520
1546
  // Test API — emit the structured job-load signal that get_fraim_job
1521
1547
  // produces before the first seekMentoring phase call.
1522
1548
  emitFraimJobLoad(runId, jobId) {
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.extractExplicitFraimInvocation = extractExplicitFraimInvocation;
4
4
  exports.fraimInvocationFor = fraimInvocationFor;
5
5
  exports.buildCommunicationStyleNote = buildCommunicationStyleNote;
6
+ exports.buildSameJobContinueMessage = buildSameJobContinueMessage;
6
7
  exports.buildManagerMessage = buildManagerMessage;
7
8
  function extractExplicitFraimInvocation(text) {
8
9
  const raw = String(text || '');
@@ -36,6 +37,18 @@ function buildCommunicationStyleNote() {
36
37
  '[How to talk to me] In your messages to me, report ONLY on the job and its outcome — what you found, what you changed, the decisions you made, blockers, and what you need from me. Do NOT narrate the FRAIM machinery: don\'t announce that you are talking to FRAIM, asking your mentor, following the process, moving between phases, or calling tools (git, playwright, etc.). I can see the raw tool activity separately if I want it. Keep your updates short and about the work, not the process.',
37
38
  ].join('\n');
38
39
  }
40
+ // Issue #732: a plain continue of the SAME active job must not re-load the job.
41
+ // Continue turns resume the existing agent session, which already has the job
42
+ // loaded, so this message carries NO `/fraim <job>` invocation — the headless
43
+ // transform therefore does not re-instruct get_fraim_job (which is slow and
44
+ // mints a fresh UUID job id every call). The manager coaching still reaches the
45
+ // agent, which advances the workflow via seekMentoring using its resumed context.
46
+ function buildSameJobContinueMessage(coaching) {
47
+ const remainder = String(coaching || '').trim();
48
+ return remainder
49
+ ? `Continue the active FRAIM job with this manager coaching:\n\n${remainder}`
50
+ : 'Continue the active FRAIM job using the current session context.';
51
+ }
39
52
  function buildManagerMessage(employeeId, jobId, kind, instructions, stubPath) {
40
53
  const trimmed = String(instructions || '').trim();
41
54
  const explicit = extractExplicitFraimInvocation(trimmed);
@@ -19,6 +19,7 @@ const defaultPreferences = (projectPath) => ({
19
19
  recentJobInstructions: {},
20
20
  personaKey: null,
21
21
  projects: normalizeAiHubProjectList([], projectPath),
22
+ removedProjectPaths: [],
22
23
  });
23
24
  function normalizeProjectPath(projectPath) {
24
25
  return path_1.default.resolve(projectPath || process.cwd());
@@ -38,6 +39,21 @@ function projectPathExists(projectPath) {
38
39
  function projectIdForPath(projectPath) {
39
40
  return `p-${(0, crypto_1.createHash)('sha1').update(canonicalProjectPath(projectPath)).digest('base64url').slice(0, 16)}`;
40
41
  }
42
+ function normalizeRemovedProjectPaths(raw, currentProjectPath) {
43
+ if (!Array.isArray(raw))
44
+ return [];
45
+ const currentKey = currentProjectPath ? canonicalProjectPath(currentProjectPath) : null;
46
+ const seen = new Set();
47
+ for (const value of raw) {
48
+ if (typeof value !== 'string' || value.trim().length === 0)
49
+ continue;
50
+ const key = canonicalProjectPath(value);
51
+ if (currentKey && key === currentKey)
52
+ continue;
53
+ seen.add(key);
54
+ }
55
+ return Array.from(seen);
56
+ }
41
57
  function uniqueProjectId(entry, seenIds) {
42
58
  const fallbackId = projectIdForPath(entry.folderPath);
43
59
  const rawId = typeof entry.id === 'string' ? entry.id.trim() : '';
@@ -86,10 +102,13 @@ function normalizeProjectEntry(raw, fallbackPath) {
86
102
  function normalizeAiHubProjectList(projects, currentProjectPath, options = {}) {
87
103
  const byPath = new Map();
88
104
  const currentKey = currentProjectPath ? normalizeProjectPath(currentProjectPath) : null;
105
+ const removedKeys = new Set(normalizeRemovedProjectPaths(options.removedProjectPaths, currentProjectPath));
89
106
  const add = (entry) => {
90
107
  if (!entry)
91
108
  return;
92
109
  const key = normalizeProjectPath(entry.folderPath);
110
+ if (removedKeys.has(canonicalProjectPath(key)))
111
+ return;
93
112
  if (!options.includeMissing && key !== currentKey && !projectPathExists(key))
94
113
  return;
95
114
  const existing = byPath.get(key);
@@ -111,6 +130,7 @@ class AiHubPreferencesStore {
111
130
  }
112
131
  try {
113
132
  const raw = JSON.parse(fs_1.default.readFileSync(this.stateFilePath, 'utf8'));
133
+ const removedProjectPaths = normalizeRemovedProjectPaths(raw.removedProjectPaths, projectPath);
114
134
  return {
115
135
  projectPath: raw.projectPath || projectPath,
116
136
  employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot') ? raw.employeeId : DEFAULT_EMPLOYEE,
@@ -121,7 +141,8 @@ class AiHubPreferencesStore {
121
141
  : {},
122
142
  personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
123
143
  apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
124
- projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath),
144
+ projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath, { removedProjectPaths }),
145
+ removedProjectPaths,
125
146
  };
126
147
  }
127
148
  catch {
@@ -132,18 +153,30 @@ class AiHubPreferencesStore {
132
153
  fs_1.default.mkdirSync(path_1.default.dirname(this.stateFilePath), { recursive: true });
133
154
  fs_1.default.writeFileSync(this.stateFilePath, JSON.stringify(preferences, null, 2));
134
155
  }
135
- saveProjects(projectPath, projects) {
156
+ saveProjects(projectPath, projects, options = {}) {
136
157
  const normalizedProjectPath = normalizeProjectPath(projectPath);
137
158
  const preferences = this.load(normalizedProjectPath);
138
- const nextProjects = normalizeAiHubProjectList(projects, normalizedProjectPath);
139
- this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects });
159
+ const reviveKeys = new Set(normalizeRemovedProjectPaths(options.reviveRemovedPaths, normalizedProjectPath));
160
+ const removedProjectPaths = normalizeRemovedProjectPaths(preferences.removedProjectPaths, normalizedProjectPath)
161
+ .filter((removedPath) => !reviveKeys.has(removedPath));
162
+ const nextProjects = normalizeAiHubProjectList(projects, normalizedProjectPath, { removedProjectPaths });
163
+ this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects, removedProjectPaths });
140
164
  return nextProjects;
141
165
  }
142
166
  mergeProjects(projectPath, projects) {
143
167
  const normalizedProjectPath = normalizeProjectPath(projectPath);
144
168
  const preferences = this.load(normalizedProjectPath);
145
- const nextProjects = normalizeAiHubProjectList([...(preferences.projects || []), ...projects], normalizedProjectPath);
146
- this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects });
169
+ const removedProjectPaths = normalizeRemovedProjectPaths(preferences.removedProjectPaths, normalizedProjectPath);
170
+ const nextProjects = normalizeAiHubProjectList([...(preferences.projects || []), ...projects], normalizedProjectPath, { removedProjectPaths });
171
+ this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects, removedProjectPaths });
172
+ return nextProjects;
173
+ }
174
+ removeProject(projectPath, projects, removedProjectPath) {
175
+ const normalizedProjectPath = normalizeProjectPath(projectPath);
176
+ const preferences = this.load(normalizedProjectPath);
177
+ const removedProjectPaths = normalizeRemovedProjectPaths([...(preferences.removedProjectPaths || []), removedProjectPath], normalizedProjectPath);
178
+ const nextProjects = normalizeAiHubProjectList(projects, normalizedProjectPath, { removedProjectPaths });
179
+ this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects, removedProjectPaths });
147
180
  return nextProjects;
148
181
  }
149
182
  remember(preferences, jobId, instructions) {
@@ -726,12 +726,17 @@ function applySeekMentoringSignal(run, signal) {
726
726
  // Issue #347: filter cross-job pollution. The agent may call
727
727
  // seekMentoring for jobs OTHER than the one this run is tracking
728
728
  // (e.g., consulting `organizational-learning-synthesis` mid-run).
729
- // Only apply signals whose jobId/jobName matches this run's jobId.
730
- // Match against either field because the agent passes both and they
731
- // typically agree, but defenses are cheap.
729
+ // Only apply signals whose job identity matches this run's.
730
+ // Issue #732: match on `jobName` the stable job-name slug that equals
731
+ // run.jobId NOT `jobId`. get_fraim_job mints a fresh randomUUID as the
732
+ // job id on every call and the agent echoes that UUID into
733
+ // seekMentoring.jobId, so a UUID never equals run.jobId. Comparing the UUID
734
+ // discarded every real phase signal as if it were foreign-job pollution and
735
+ // froze the tracker at "no phases done". The UUID cannot positively identify
736
+ // this run's job, so it must not participate in the match.
732
737
  const targetJobId = run.jobId;
733
- const callJobId = signal.jobId || signal.jobName;
734
- if (callJobId && targetJobId && callJobId !== targetJobId)
738
+ const callJobName = signal.jobName;
739
+ if (callJobName && targetJobId && callJobName !== targetJobId)
735
740
  return;
736
741
  if (signal.reviewHandoff) {
737
742
  const normalizedReviewHandoff = normalizeReviewHandoff(signal.reviewHandoff);
@@ -1382,10 +1387,7 @@ class AiHubServer {
1382
1387
  ...(preferences.projects || []),
1383
1388
  ...conversationProjects,
1384
1389
  ...extras,
1385
- ], normalizedProjectPath);
1386
- }
1387
- saveKnownProjects(projectPath, projects) {
1388
- return this.preferencesStore.saveProjects(path_1.default.resolve(projectPath || this.projectPath), this.knownProjects(projectPath, projects));
1390
+ ], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
1389
1391
  }
1390
1392
  async bootstrapResponse(projectPath, apiKey) {
1391
1393
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
@@ -1865,7 +1867,10 @@ class AiHubServer {
1865
1867
  return null;
1866
1868
  }
1867
1869
  applySeekMentoringSignalToRun(run, signal) {
1868
- this.maybePromoteFreeformRunToJob(run, signal.jobId || signal.jobName);
1870
+ // Issue #732: promote using the stable jobName slug, not the per-call UUID
1871
+ // jobId (resolveHubJob would never match a UUID, leaving a freeform run
1872
+ // unpromoted and then dropping its phase signals).
1873
+ this.maybePromoteFreeformRunToJob(run, signal.jobName || signal.jobId);
1869
1874
  applySeekMentoringSignal(run, signal);
1870
1875
  }
1871
1876
  applyFraimJobSignalToRun(run, signal) {
@@ -1893,15 +1898,17 @@ class AiHubServer {
1893
1898
  if (!resolvedJobId) {
1894
1899
  throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
1895
1900
  }
1896
- // #696: `display` is the manager conversation bubble. Keep the full generated
1897
- // turn in `message`, but show only the host-specific FRAIM invocation.
1898
- const display = (0, manager_turns_1.fraimInvocationFor)(hostId, resolvedJobId) || (explicit?.remainder || instructions || '').trim();
1901
+ // `display` is the manager conversation bubble. It keeps the user's full
1902
+ // coaching text with the host-specific FRAIM invocation. The agent payload
1903
+ // still receives Hub-only helper blocks such as `[FRAIM shared browser]`
1904
+ // and `[How to talk to me]`; those are omitted from the visible bubble.
1899
1905
  // #521: the shared-browser guidance is injected HERE, at the Hub layer — never
1900
1906
  // baked into the registry job/skill. It only appears when a shared browser is
1901
1907
  // available (env published at boot).
1902
1908
  const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
1903
1909
  const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
1904
1910
  if (resolvedJobId === '__freeform__') {
1911
+ const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
1905
1912
  return {
1906
1913
  jobId: resolvedJobId,
1907
1914
  message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
@@ -1912,6 +1919,7 @@ class AiHubServer {
1912
1919
  const absoluteStubPath = resolvedJob?.stubPath
1913
1920
  ? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
1914
1921
  : undefined;
1922
+ const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
1915
1923
  return {
1916
1924
  jobId: resolvedJobId,
1917
1925
  message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
@@ -1926,14 +1934,27 @@ class AiHubServer {
1926
1934
  // passes raw invocation syntax.
1927
1935
  const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1928
1936
  const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
1929
- // #696: the thread shows the invocation that the Hub sent, while the employee
1930
- // still receives the full manager turn plus the style note.
1937
+ // #730: the manager thread shows the coaching turn with high fidelity the
1938
+ // correct FRAIM command in front plus what the manager actually said — built
1939
+ // from the shared normalizer, exactly like the start path. With no
1940
+ // instructions (e.g. a plain coaching-template click) buildManagerMessage
1941
+ // collapses to just the invocation. The employee additionally receives the
1942
+ // communication-style note (and, per #732 below, a lighter same-job payload);
1943
+ // those operational details stay out of the visible bubble.
1931
1944
  const userText = (explicit?.remainder || instructions || '').trim();
1932
- const display = (0, manager_turns_1.fraimInvocationFor)(run.hostId, effectiveJobId) || userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
1933
- return {
1934
- message: (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)(),
1935
- display,
1936
- };
1945
+ const display = (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions);
1946
+ // Issue #732: a plain continue of the SAME active real job must not re-load
1947
+ // the job via get_fraim_job on every coaching turn — the host session is
1948
+ // resumed with the job already in context, so re-fetching is wasted latency
1949
+ // and tokens (the reported hub slowness) and mints a fresh UUID job id each
1950
+ // time. Only load the job when the continue SWITCHES jobs (a coaching
1951
+ // template or an explicit /fraim <other>). Freeform/adhoc runs keep the
1952
+ // existing buildManagerMessage path (which already emits no invocation).
1953
+ const switchesJob = effectiveJobId !== run.jobId;
1954
+ const message = (switchesJob || run.jobId === '__freeform__')
1955
+ ? (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)()
1956
+ : (0, manager_turns_1.buildSameJobContinueMessage)(userText) + (0, manager_turns_1.buildCommunicationStyleNote)();
1957
+ return { message, display };
1937
1958
  }
1938
1959
  async computePersonas(apiKey) {
1939
1960
  const allBundles = listHubPersonaBundles();
@@ -2270,7 +2291,7 @@ class AiHubServer {
2270
2291
  if (!Array.isArray(body.projects)) {
2271
2292
  return res.status(400).json({ error: 'projects array required' });
2272
2293
  }
2273
- const projects = this.saveKnownProjects(projectPath, body.projects);
2294
+ const projects = this.preferencesStore.saveProjects(projectPath, [...this.knownProjects(projectPath), ...body.projects], { reviveRemovedPaths: Array.isArray(body.reviveRemovedPaths) ? body.reviveRemovedPaths : [] });
2274
2295
  return res.json({ projectPath, projects, source: 'disk' });
2275
2296
  }
2276
2297
  catch (error) {
@@ -2312,9 +2333,9 @@ class AiHubServer {
2312
2333
  // Delete the conversation-store KEY — an empty list would still re-derive
2313
2334
  // the project via listProjectPaths (R9).
2314
2335
  this.conversationStore.removeProject(entry.folderPath);
2315
- // Persist the filtered list directly: saveKnownProjects unions with the
2316
- // derived list by design and cannot express removal.
2317
- const projects = this.preferencesStore.saveProjects(projectPath, known.filter((project) => project.id !== entry.id));
2336
+ // Persist the filtered list with a tombstone: a normal project save
2337
+ // still merges derived projects and cannot express removal.
2338
+ const projects = this.preferencesStore.removeProject(projectPath, known.filter((project) => project.id !== entry.id), entry.folderPath);
2318
2339
  return res.json({ ok: true, projects });
2319
2340
  });
2320
2341
  this.app.post('/api/ai-hub/api-key', (req, res) => {
@@ -1052,6 +1052,27 @@ class FraimLocalMCPServer {
1052
1052
  this.collectStrings(response.result, strings);
1053
1053
  return strings.some(s => /\{\{proxy\.delivery\.[^}]+\}\}/.test(s));
1054
1054
  }
1055
+ getDeliveryPlaceholderKeys(response) {
1056
+ const keys = new Set();
1057
+ if (!response.result)
1058
+ return keys;
1059
+ const strings = [];
1060
+ this.collectStrings(response.result, strings);
1061
+ for (const text of strings) {
1062
+ for (const match of text.matchAll(/\{\{proxy\.delivery\.([^}]+)\}\}/g)) {
1063
+ keys.add(`proxy.delivery.${match[1].trim()}`);
1064
+ }
1065
+ }
1066
+ return keys;
1067
+ }
1068
+ deliveryTemplatesCoverPlaceholders(templates, response) {
1069
+ const requiredKeys = this.getDeliveryPlaceholderKeys(response);
1070
+ for (const key of requiredKeys) {
1071
+ if (!(key in templates))
1072
+ return false;
1073
+ }
1074
+ return true;
1075
+ }
1055
1076
  responseHasPotentialProviderPlaceholders(response) {
1056
1077
  if (!response.result)
1057
1078
  return false;
@@ -1123,15 +1144,17 @@ class FraimLocalMCPServer {
1123
1144
  if (!this.responseContainsDeliveryPlaceholders(response))
1124
1145
  return;
1125
1146
  const engine = this.ensureEngine();
1126
- if (engine.hasDeliveryTemplates())
1127
- return;
1128
- if (engine.loadDeliveryTemplates())
1147
+ const currentTemplates = engine.loadDeliveryTemplates();
1148
+ // Normal proxy.delivery substitution is correct once the provider contains the
1149
+ // key. Older local registry caches can predate newly added delivery keys, so
1150
+ // treat missing required keys as stale cache and refresh from the server.
1151
+ if (currentTemplates && this.deliveryTemplatesCoverPlaceholders(currentTemplates, response))
1129
1152
  return;
1130
1153
  const filename = this.getWorkingStyle() === 'Conversation'
1131
1154
  ? 'delivery-conversation.json'
1132
1155
  : 'delivery-pr.json';
1133
1156
  const cached = this.readCachedTemplateFile(filename);
1134
- if (cached) {
1157
+ if (cached && this.deliveryTemplatesCoverPlaceholders(cached, response)) {
1135
1158
  engine.setDeliveryTemplates(cached);
1136
1159
  return;
1137
1160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.192",
3
+ "version": "2.0.194",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1011,6 +1011,21 @@ function aggregateDotClass(dots) {
1011
1011
 
1012
1012
  function renderRail() {
1013
1013
  renderTeamRoster();
1014
+ const activeRailProjectKey = (typeof tf !== 'undefined' && tf && tf.activeProjectId)
1015
+ || state.projectPath
1016
+ || 'current';
1017
+ const employeeGroupOpenState = new Map();
1018
+ els['conv-list'].querySelectorAll('details.conv-employee-group[data-employee-key]').forEach((details) => {
1019
+ const key = details.dataset ? details.dataset.employeeKey : '';
1020
+ const projectKey = details.dataset ? (details.dataset.projectKey || activeRailProjectKey) : activeRailProjectKey;
1021
+ if (key) employeeGroupOpenState.set(projectKey + ':' + key, details.open);
1022
+ });
1023
+ const employeeGroupStateKey = (key) => activeRailProjectKey + ':' + key;
1024
+ const preservedEmployeeGroupOpen = (key) => (
1025
+ employeeGroupOpenState.has(employeeGroupStateKey(key))
1026
+ ? employeeGroupOpenState.get(employeeGroupStateKey(key))
1027
+ : true
1028
+ );
1014
1029
  els['conv-list'].innerHTML = '';
1015
1030
  // R4: filter by selected persona when one is active.
1016
1031
  // #693 R1: the "Project Updates" section was retired; project-lifecycle runs
@@ -1167,7 +1182,9 @@ function renderRail() {
1167
1182
  for (const group of groups.values()) {
1168
1183
  const details = document.createElement('details');
1169
1184
  details.className = 'conv-employee-group';
1170
- details.open = true;
1185
+ details.dataset.employeeKey = group.key;
1186
+ details.dataset.projectKey = activeRailProjectKey;
1187
+ details.open = preservedEmployeeGroupOpen(group.key);
1171
1188
  const summary = document.createElement('summary');
1172
1189
  summary.className = 'conv-employee-tab';
1173
1190
  const avatar = buildConversationEmployeeAvatar(group.sample, 'conv-employee-avatar');
@@ -1237,7 +1254,9 @@ function renderRail() {
1237
1254
  if (watercoolerConvs.length > 0) {
1238
1255
  const wcDetails = document.createElement('details');
1239
1256
  wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
1240
- wcDetails.open = true;
1257
+ wcDetails.dataset.employeeKey = '__watercooler__';
1258
+ wcDetails.dataset.projectKey = activeRailProjectKey;
1259
+ wcDetails.open = preservedEmployeeGroupOpen('__watercooler__');
1241
1260
  const wcSummary = document.createElement('summary');
1242
1261
  wcSummary.className = 'conv-employee-tab';
1243
1262
  // #693.8: watercooler icon (replaces the blank dashed placeholder).
@@ -2918,11 +2937,19 @@ function humanizeSlug(slug) {
2918
2937
  .join(' ');
2919
2938
  }
2920
2939
 
2921
- function stripStubReference(text) {
2922
- return String(text || '')
2940
+ function stripStubReference(text, options = {}) {
2941
+ const cleaned = String(text || '')
2923
2942
  .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
2924
- .replace(/\[Thought:\s*true\]\s*/gi, '')
2925
- .replace(/\s+/g, ' ')
2943
+ .replace(/\[Thought:\s*true\]\s*/gi, '');
2944
+ if (options.preserveWhitespace) {
2945
+ return cleaned.replace(/\n{3,}/g, '\n\n').trim();
2946
+ }
2947
+ return cleaned.replace(/\s+/g, ' ').trim();
2948
+ }
2949
+
2950
+ function managerBubbleText(text) {
2951
+ return stripHubInjectedNotes(stripStubReference(text, { preserveWhitespace: true }))
2952
+ .replace(/\n{3,}/g, '\n\n')
2926
2953
  .trim();
2927
2954
  }
2928
2955
 
@@ -3029,8 +3056,7 @@ function appendMessageDom(role, text, conv) {
3029
3056
  const content = useSurfaced ? surfaced : raw;
3030
3057
  bubble.innerHTML = formatEmployeeText(content || (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…'));
3031
3058
  } else {
3032
- const surfaced = surfaceText(role, text, conv);
3033
- bubble.textContent = surfaced || text;
3059
+ bubble.textContent = role === 'manager' ? managerBubbleText(text) : (surfaceText(role, text, conv) || text);
3034
3060
  }
3035
3061
  if (role === 'manager') {
3036
3062
  const raw = document.createElement('span');
@@ -6400,13 +6426,14 @@ function tfLoadStorage() {
6400
6426
  try { tf.assignments = JSON.parse(window.localStorage.getItem(STORAGE_KEY_ASSIGNMENTS_512) || '{}'); }
6401
6427
  catch { tf.assignments = {}; }
6402
6428
  }
6403
- function tfPersistProjects() {
6429
+ function tfPersistProjects(options) {
6404
6430
  try { window.localStorage.setItem(STORAGE_KEY_PROJECTS_512, JSON.stringify(tf.projects)); } catch {}
6405
6431
  if (!state.projectPath) return;
6432
+ const reviveRemovedPaths = options && Array.isArray(options.reviveRemovedPaths) ? options.reviveRemovedPaths : [];
6406
6433
  requestJson('/api/ai-hub/projects', {
6407
6434
  method: 'PUT',
6408
6435
  headers: { 'Content-Type': 'application/json' },
6409
- body: JSON.stringify({ projectPath: state.projectPath, projects: tf.projects }),
6436
+ body: JSON.stringify({ projectPath: state.projectPath, projects: tf.projects, reviveRemovedPaths }),
6410
6437
  }).catch((error) => console.warn('[512] Could not persist projects to local disk:', error));
6411
6438
  }
6412
6439
  function tfPersistAssignments() {
@@ -9400,32 +9427,32 @@ function tfCloseConnectedSurface() {
9400
9427
  tfShowArea(returnArea);
9401
9428
  }
9402
9429
 
9403
- async function tfHandleConnectedAuthMessage(event) {
9404
- const frame = document.getElementById('connected-frame');
9405
- if (frame && frame.contentWindow && event.source !== frame.contentWindow) return;
9406
- let remoteOrigin = '';
9407
- try { remoteOrigin = new URL(tfConnectedRemoteBase()).origin; }
9430
+ async function tfHandleConnectedAuthMessage(event) {
9431
+ const frame = document.getElementById('connected-frame');
9432
+ if (frame && frame.contentWindow && event.source !== frame.contentWindow) return;
9433
+ let remoteOrigin = '';
9434
+ try { remoteOrigin = new URL(tfConnectedRemoteBase()).origin; }
9408
9435
  catch { return; }
9409
9436
  if (event.origin !== remoteOrigin) return;
9410
- const msg = event.data || {};
9411
- if (!msg || msg.type !== 'fraim-auth-api-key' || typeof msg.apiKey !== 'string' || !msg.apiKey) return;
9412
- const previousKey = tfConnectedApiKey();
9413
- await tfRememberConnectedApiKey(msg.apiKey);
9414
- if (state.connectedSurface) {
9415
- const nextUrl = tfConnectedSurfaceUrl(state.connectedSurface);
9416
- const frameEl = document.getElementById('connected-frame');
9417
- if (frameEl) {
9418
- try {
9419
- const current = new URL(frameEl.src || '', window.location.href);
9420
- const next = new URL(nextUrl, window.location.href);
9421
- if (previousKey === msg.apiKey && current.origin === next.origin && current.pathname === next.pathname) {
9422
- return;
9423
- }
9424
- } catch {}
9425
- frameEl.src = nextUrl;
9426
- }
9427
- }
9428
- }
9437
+ const msg = event.data || {};
9438
+ if (!msg || msg.type !== 'fraim-auth-api-key' || typeof msg.apiKey !== 'string' || !msg.apiKey) return;
9439
+ const previousKey = tfConnectedApiKey();
9440
+ await tfRememberConnectedApiKey(msg.apiKey);
9441
+ if (state.connectedSurface) {
9442
+ const nextUrl = tfConnectedSurfaceUrl(state.connectedSurface);
9443
+ const frameEl = document.getElementById('connected-frame');
9444
+ if (frameEl) {
9445
+ try {
9446
+ const current = new URL(frameEl.src || '', window.location.href);
9447
+ const next = new URL(nextUrl, window.location.href);
9448
+ if (previousKey === msg.apiKey && current.origin === next.origin && current.pathname === next.pathname) {
9449
+ return;
9450
+ }
9451
+ } catch {}
9452
+ frameEl.src = nextUrl;
9453
+ }
9454
+ }
9455
+ }
9429
9456
 
9430
9457
  async function tfRememberConnectedApiKey(apiKey) {
9431
9458
  const changed = apiKey !== state.storedApiKey;
@@ -9808,7 +9835,7 @@ async function tfCreateProject() {
9808
9835
  };
9809
9836
  tf.projects.push(project);
9810
9837
  tf.assignments[id] = [];
9811
- tfPersistProjects();
9838
+ tfPersistProjects({ reviveRemovedPaths: [folderPath] });
9812
9839
  tfPersistAssignments();
9813
9840
  tf.activeProjectId = id;
9814
9841
  tfCloseNewProject();
@@ -1503,6 +1503,7 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
1503
1503
  .message.manager .bubble {
1504
1504
  background: var(--accent);
1505
1505
  color: #fff;
1506
+ white-space: pre-wrap;
1506
1507
  }
1507
1508
  .message.employee .bubble {
1508
1509
  background: var(--surface);
@@ -2752,7 +2753,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2752
2753
  .am-av { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; flex-shrink: 0; }
2753
2754
  .am-name { font-size: 13px; font-weight: 700; }
2754
2755
  .am-email { font-size: 11px; color: var(--muted); }
2755
- .am-item { display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 9px 16px; cursor: pointer; font-size: 13px; color: var(--text); text-align: left; text-decoration: none; border: 0; background: none; }
2756
+ .am-item { display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 9px 16px; cursor: pointer; font-size: 13px; color: var(--text); text-align: left; text-decoration: none; border: 0; background: none; }
2756
2757
  .am-item:hover { background: var(--bg); }
2757
2758
  .am-ico { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; margin-top: 1px; }
2758
2759
  .am-label { font-weight: 600; }
@@ -2768,75 +2769,75 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2768
2769
  .am-theme[aria-checked="true"] .am-switch-knob { transform: translateX(16px); }
2769
2770
 
2770
2771
  /* Hub areas */
2771
- .hub-area { display: none; flex: 1; overflow: hidden; }
2772
- .hub-area.on { display: flex; flex-direction: column; }
2773
- .hub-area-page { max-width: 860px; margin: 0 auto; padding: 28px 24px 48px; overflow-y: auto; flex: 1; /* authoritative rule — see also Round-2 override below which is now removed */ }
2774
-
2775
- .connected-shell {
2776
- display: flex;
2777
- flex: 1;
2778
- min-height: 0;
2779
- flex-direction: column;
2780
- background: var(--bg);
2781
- }
2782
- .connected-toolbar {
2783
- display: flex;
2784
- align-items: center;
2785
- gap: 14px;
2786
- min-height: 52px;
2787
- padding: 8px 16px;
2788
- background: var(--surface);
2789
- border-bottom: 1px solid var(--line);
2790
- flex-shrink: 0;
2791
- }
2792
- .connected-title-block {
2793
- min-width: 0;
2794
- display: flex;
2795
- flex-direction: column;
2796
- gap: 1px;
2797
- }
2798
- .connected-kicker {
2799
- font-size: 10px;
2800
- font-weight: 700;
2801
- letter-spacing: .04em;
2802
- text-transform: uppercase;
2803
- color: var(--muted);
2804
- }
2805
- .connected-title {
2806
- font-size: 14px;
2807
- font-weight: 700;
2808
- color: var(--text);
2809
- }
2810
- .connected-origin {
2811
- min-width: 0;
2812
- flex: 1;
2813
- color: var(--muted);
2814
- font-size: 12px;
2815
- overflow: hidden;
2816
- text-overflow: ellipsis;
2817
- white-space: nowrap;
2818
- }
2819
- .connected-close {
2820
- border: 1px solid var(--line);
2821
- background: var(--surface);
2822
- color: var(--text);
2823
- border-radius: 8px;
2824
- padding: 6px 12px;
2825
- font-size: 13px;
2826
- font-weight: 600;
2827
- flex-shrink: 0;
2828
- }
2829
- .connected-close:hover { background: var(--bg); }
2830
- .connected-frame {
2831
- flex: 1;
2832
- min-height: 0;
2833
- width: 100%;
2834
- border: 0;
2835
- background: var(--surface);
2836
- }
2837
-
2838
- /* #521 R6: Company/Manager use the same rail + main shell as the project workspace. */
2839
- .area-shell { display: flex; flex: 1; min-height: 0; }
2772
+ .hub-area { display: none; flex: 1; overflow: hidden; }
2773
+ .hub-area.on { display: flex; flex-direction: column; }
2774
+ .hub-area-page { max-width: 860px; margin: 0 auto; padding: 28px 24px 48px; overflow-y: auto; flex: 1; /* authoritative rule — see also Round-2 override below which is now removed */ }
2775
+
2776
+ .connected-shell {
2777
+ display: flex;
2778
+ flex: 1;
2779
+ min-height: 0;
2780
+ flex-direction: column;
2781
+ background: var(--bg);
2782
+ }
2783
+ .connected-toolbar {
2784
+ display: flex;
2785
+ align-items: center;
2786
+ gap: 14px;
2787
+ min-height: 52px;
2788
+ padding: 8px 16px;
2789
+ background: var(--surface);
2790
+ border-bottom: 1px solid var(--line);
2791
+ flex-shrink: 0;
2792
+ }
2793
+ .connected-title-block {
2794
+ min-width: 0;
2795
+ display: flex;
2796
+ flex-direction: column;
2797
+ gap: 1px;
2798
+ }
2799
+ .connected-kicker {
2800
+ font-size: 10px;
2801
+ font-weight: 700;
2802
+ letter-spacing: .04em;
2803
+ text-transform: uppercase;
2804
+ color: var(--muted);
2805
+ }
2806
+ .connected-title {
2807
+ font-size: 14px;
2808
+ font-weight: 700;
2809
+ color: var(--text);
2810
+ }
2811
+ .connected-origin {
2812
+ min-width: 0;
2813
+ flex: 1;
2814
+ color: var(--muted);
2815
+ font-size: 12px;
2816
+ overflow: hidden;
2817
+ text-overflow: ellipsis;
2818
+ white-space: nowrap;
2819
+ }
2820
+ .connected-close {
2821
+ border: 1px solid var(--line);
2822
+ background: var(--surface);
2823
+ color: var(--text);
2824
+ border-radius: 8px;
2825
+ padding: 6px 12px;
2826
+ font-size: 13px;
2827
+ font-weight: 600;
2828
+ flex-shrink: 0;
2829
+ }
2830
+ .connected-close:hover { background: var(--bg); }
2831
+ .connected-frame {
2832
+ flex: 1;
2833
+ min-height: 0;
2834
+ width: 100%;
2835
+ border: 0;
2836
+ background: var(--surface);
2837
+ }
2838
+
2839
+ /* #521 R6: Company/Manager use the same rail + main shell as the project workspace. */
2840
+ .area-shell { display: flex; flex: 1; min-height: 0; }
2840
2841
  .area-rail { width: 244px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--line); overflow-y: auto; padding: 6px 0; }
2841
2842
  .area-main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
2842
2843
  .area-rail-head { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding: 14px 16px 6px; }